Compare commits
59 Commits
wrapper-ma
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
ac9953fed6 | ||
|
9b94280e8b | ||
|
f3bc0ef670 | ||
|
3d807e2fc2 | ||
|
23e122c08a | ||
b56ed48f8d | |||
|
ebbebbacf7 | ||
|
1ede582a76 | ||
|
77f92abe19 | ||
|
0349c36880 | ||
|
dab1e4e9e0 | ||
|
fe09e1dd1f | ||
|
73175bbecd | ||
|
730d479e2d | ||
|
4e86eb43fc | ||
|
fdd336fe91 | ||
|
d768712b75 | ||
|
985888fb5a | ||
|
87bcc61a1a | ||
|
66447865f5 | ||
|
47cc285919 | ||
|
21a79194dd | ||
|
aab931b543 | ||
|
21f6150842 | ||
|
4f0e000c93 | ||
|
b6980794d7 | ||
|
8fb7a9dab5 | ||
|
eb1e1da82f | ||
|
7c9ec713b7 | ||
|
40ef32c530 | ||
|
95d91b6f3e | ||
|
a25dca76e7 | ||
|
b780c4069e | ||
|
0038a1889c | ||
|
2a898802b6 | ||
|
cd11e3106f | ||
|
5f71ee8ebf | ||
|
6e85897ca8 | ||
|
b74002e69f | ||
|
7b5bcff6ac | ||
|
f14e84b89c | ||
|
c09f36162d | ||
|
268966a4ae | ||
|
4e77cddc92 | ||
|
1febca2243 | ||
|
e2b6dd667b | ||
|
4d5258c89d | ||
|
c28bc573cc | ||
|
377586e6e7 | ||
|
27cc972f19 | ||
|
c540f624af | ||
|
7222239774 | ||
|
5368d21be4 | ||
|
9bdb286d38 | ||
|
979a5ecd4f | ||
|
b152440241 | ||
|
e6ccc86629 | ||
|
36939efac3 | ||
|
dee0c4a5b4 |
127
.env.example
Normal file
@ -0,0 +1,127 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Required
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DISCORD_TOKEN=<paste your bot token>
|
||||
|
||||
# Git repo to run (wrapper clones nightly and on boot)
|
||||
REPO_URL=<https://git.example.com/you/shaiwatcher.git>
|
||||
REPO_BRANCH=main
|
||||
# If your repo is private, you can use a token; leave empty if not used
|
||||
REPO_TOKEN=
|
||||
|
||||
# Wrapper update time (UTC) & test bump policy
|
||||
CHECK_TIME_UTC=03:00
|
||||
IGNORE_TEST_LEVEL=1 # 1 = ignore updates that change only the -T test suffix
|
||||
|
||||
# Optional commit subject feed used by your boot notice
|
||||
SHAI_REPO_RSS=<https://git.example.com/you/shaiwatcher.rss>
|
||||
|
||||
# Where the bot stores data inside the container (don’t change)
|
||||
SHAI_DATA_FILE=/data/data.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Volumes (external, never wiped by stack re-deploys)
|
||||
# Create once: docker volume create shaiwatcher_data && docker volume create shaiwatcher_cache
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_VOL_DATA=shaiwatcher_data
|
||||
SHAI_VOL_CACHE=shaiwatcher_cache
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Playwright / Headless browser (for DD scraping)
|
||||
# Build with: docker compose build --build-arg WITH_PLAYWRIGHT=1
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
WITH_PLAYWRIGHT=1 # 1 to bake Chromium+Playwright into the image
|
||||
SHAI_DD_CHANNEL_ID=<channel id for DD message>
|
||||
SHAI_DD_FETCHER=playwright # playwright|aiohttp (playwright recommended)
|
||||
SHAI_DD_PW_TIMEOUT_MS=60000 # nav timeout
|
||||
SHAI_DD_PW_WAIT= # domcontentloaded|load|networkidle (empty = default domcontentloaded)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Slash command scope (your bot already honors this)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_SLASH_GUILD_ONLY=true
|
||||
SHAI_HOME_GUILD_ID=<home guild id>
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Channels
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_MOD_CHANNEL_ID=<id>
|
||||
SHAI_MODLOG_CHANNEL_ID=<id>
|
||||
SHAI_USERSLIST_CHANNEL_ID=<id>
|
||||
SHAI_REPORT_CHANNEL_ID=<id>
|
||||
SHAI_PIRATES_LIST_CHANNEL_ID=<id>
|
||||
SHAI_TRIGGER_CHANNEL_ID=<id> # Auto-VC
|
||||
SHAI_AUTO_VC_CATEGORY_ID=<id>
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Reaction messages
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_RULES_MESSAGE_ID=<id>
|
||||
SHAI_ENGAGEMENT_MESSAGE_ID=<id>
|
||||
SHAI_NICKNAME_MESSAGE_ID=<id>
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Roles
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_RULES_ROLE_ID=<id>
|
||||
SHAI_ENGAGEMENT_ROLE_ID=<id>
|
||||
SHAI_FULL_ACCESS_ROLE_ID=<id>
|
||||
SHAI_ADMIN_ROLE_ID=<id>
|
||||
SHAI_FIELD_MOD_ROLE_ID=<id>
|
||||
SHAI_INTEL_MOD_ROLE_ID=<id>
|
||||
SHAI_MODERATOR_ROLE_ID=<id>
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Auto-VC
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_VC_NAME_PREFIX=DD Crew
|
||||
SHAI_AUTO_VC_CLEANUP_DELAY=30
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Threat weights
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_THREAT_W_KILL=0.30
|
||||
SHAI_THREAT_W_DESTRUCTION=0.40
|
||||
SHAI_THREAT_W_GROUP=0.20
|
||||
SHAI_THREAT_W_SKILL=0.10
|
||||
SHAI_THREAT_GROUP_THRESHOLD=3
|
||||
SHAI_THREAT_MIN_SAMPLES_FOR_STATS=3
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Toggles
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_NICK_NUDGE_LOOP_ENABLED=false
|
||||
SHAI_USER_CARDS_CRON_ENABLED=true
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# SpicePay defaults
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_SPICEPAY_LSR_CUT_PERCENT=10
|
||||
SHAI_SPICEPAY_BASE_WEIGHT=25
|
||||
SHAI_SPICEPAY_CARRIER_BONUS=12.5
|
||||
SHAI_SPICEPAY_CRAWLER_BONUS=12.5
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Emojis (IDs)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_EMOJI_MELANGE_ID=<id>
|
||||
SHAI_EMOJI_SAND_ID=<id>
|
||||
SHAI_EMOJI_CARRIER_CRAWLER_ID=<id>
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Docs site (optional)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SHAI_DOCS_HOST=0.0.0.0
|
||||
SHAI_DOCS_PORT=8910
|
||||
SHAI_DOCS_TITLE=ShaiWatcher Commands
|
||||
SHAI_DOCS_SUPPORT_URL=
|
||||
SHAI_DOCS_SUPPORT_LABEL=Buy me a ☕
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Wrapper knobs (optional)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PIP_INSTALL_REQUIREMENTS=1
|
||||
WRAPPER_STOP_TIMEOUT=25
|
||||
|
||||
# Keep Docker base locale happy
|
||||
LANG=C.UTF-8
|
11
.gitignore
vendored
@ -9,4 +9,13 @@ venv/
|
||||
data/
|
||||
data.json
|
||||
data.json.bak
|
||||
settings*.conf
|
||||
settings*.conf
|
||||
NOTES.md
|
||||
sanity/
|
||||
.offline_data.json
|
||||
dev/.env.production
|
||||
dev/portainer_config.png
|
||||
|
||||
# Tools
|
||||
wrapper/
|
||||
wrapper/tools/
|
BIN
assets/docs/ShaiWatcher.png
Normal file
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/docs/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
assets/docs/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 417 KiB |
BIN
assets/docs/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 46 KiB |
568
assets/docs/cmd.html
Normal file
@ -0,0 +1,568 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
|
||||
<!-- Favicons (put files in /assets/docs/) -->
|
||||
<link rel="icon" type="image/png" href="/assets/docs/favicon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/docs/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/docs/favicon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/docs/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/assets/docs/site.webmanifest">
|
||||
|
||||
<title>__TITLE__</title>
|
||||
<style>
|
||||
:root { --bg:#0b0f14; --panel:#121922; --muted:#6b7280; --fg:#e5e7eb; --accent:#60a5fa; --sticky-top: 8px; --header-h: 56px; }
|
||||
* { box-sizing:border-box; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg); }
|
||||
|
||||
/* Header: 2/3 title, 1/3 actions */
|
||||
header { padding:16px 20px; background:#0f172a; border-bottom:1px solid #223; position:sticky; top:0; z-index:3; }
|
||||
.hdr { display:flex; align-items:center; gap:12px; }
|
||||
.hdr .title { flex: 2 1 66%; font-size:20px; font-weight:600; }
|
||||
.hdr .actions { flex: 1 1 34%; display:flex; justify-content:flex-end; gap:8px; }
|
||||
|
||||
main { max-width:1200px; margin:20px auto; padding:0 16px 40px; }
|
||||
.row { display:flex; gap:16px; flex-wrap:wrap; }
|
||||
.col { flex:1 1 560px; min-width:320px; }
|
||||
.panel { background:var(--panel); border:1px solid #1f2937; border-radius:12px; padding:16px; }
|
||||
|
||||
.toolbar { margin-bottom:16px; position:sticky; top: var(--header-h); z-index:2; }
|
||||
.search { width:100%; padding:10px 12px; border-radius:8px; border:1px solid #1f2937; background:#0b1220; color:var(--fg); }
|
||||
|
||||
.list { margin-top:12px; display:flex; flex-direction:column; gap:10px; transition: filter .2s ease; }
|
||||
.card { border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; cursor:default; scroll-margin-top: calc(var(--sticky-top) + 12px); }
|
||||
.name { font-weight:600; display:flex; align-items:center; gap:8px; }
|
||||
.meta { font-size:12px; color:var(--muted); display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
|
||||
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #2b4; }
|
||||
.pill.mod { border-color:#ef4444; color:#fecaca; }
|
||||
.pill.admin { border-color:#a78bfa; color:#e9d5ff; }
|
||||
.pill.slash { border-color:#60a5fa; }
|
||||
.pill.prefix { border-color:#f59e0b; }
|
||||
.pill.hybrid { border-color:#34d399; }
|
||||
|
||||
/* Usage block + copy button */
|
||||
.usage{
|
||||
--lh:1.6; /* line-height used to size the button */
|
||||
line-height:var(--lh);
|
||||
position:relative;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size:12px;
|
||||
background:#0a1220;
|
||||
border:1px dashed #1f2937;
|
||||
padding:6px 40px 6px 8px; /* right padding to make room for the button */
|
||||
border-radius:6px; margin-top:6px; word-break:break-word;
|
||||
}
|
||||
.usage > .copybtn{
|
||||
position:absolute; right:0; top:0;
|
||||
height: calc(var(--lh) * 1em + 12px); /* one text line + vertical padding */
|
||||
width:36px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
background:#374151; border:0; color:#e5e7eb; border-radius:0 6px 0 6px;
|
||||
cursor:pointer;
|
||||
}
|
||||
.usage > .copybtn:hover{ background:#4b5563; }
|
||||
@media (max-width: 900px){ .usage > .copybtn{ width:auto; padding:0 10px; } }
|
||||
|
||||
.help { margin-top:6px; color:#cbd5e1; }
|
||||
.btn { padding:4px 8px; border:1px solid #334155; border-radius:8px; background:#0b1220; color:#e5e7eb; cursor:pointer; font-size:12px; }
|
||||
.btn:hover { background:#0f172a; }
|
||||
.btn-icon { width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; padding:0; }
|
||||
.btn-row { display:flex; gap:8px; align-items:center; }
|
||||
|
||||
.detailsbox { margin-top:12px; position: sticky; top: var(--sticky-top); min-height: 280px; z-index:1; transition: transform .25s ease; }
|
||||
|
||||
/* Footer */
|
||||
footer { margin-top:16px; color:var(--muted); font-size:12px; text-align:center; }
|
||||
footer .line { margin:4px 0; }
|
||||
footer a { color: var(--fg); text-decoration: none; border-bottom: 1px dotted #334155; }
|
||||
footer a:hover { text-decoration: underline; }
|
||||
|
||||
/* Discord drawer (right-side sheet) */
|
||||
.drawer{ position:fixed; top:var(--header-h); right:0; width:clamp(360px,33vw,420px);
|
||||
height:calc(100vh - var(--header-h) - 16px); background:var(--panel); border-left:1px solid #1f2937;
|
||||
box-shadow:-16px 0 40px rgba(0,0,0,.45); transform:translateX(100%); transition:transform .25s ease;
|
||||
z-index:6; border-top-left-radius:12px; border-bottom-left-radius:12px; overflow:hidden; }
|
||||
.drawer.open{ transform:translateX(0); }
|
||||
.drawer-close{ position:absolute; top:8px; right:8px; z-index:1; }
|
||||
.drawer-backdrop{ position:fixed; inset:var(--header-h) 0 0 0; background:rgba(0,0,0,.35); display:none; z-index:5; }
|
||||
.drawer-backdrop.open{ display:block; }
|
||||
|
||||
/* Blur veil under toolbar */
|
||||
.veil{ position:sticky; top:var(--header-h); height:16px; pointer-events:none; z-index:2;
|
||||
backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
|
||||
mask-image:linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
|
||||
-webkit-mask-image:linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1)); }
|
||||
|
||||
/* Mobile details as sheet */
|
||||
@media (max-width: 900px){
|
||||
.row{ display:block; }
|
||||
.detailsbox{ position:fixed; right:0; top:var(--header-h); width:96vw; max-width:720px;
|
||||
height:calc(100vh - var(--header-h) - 16px); overflow:auto; transform:translateX(100%);
|
||||
box-shadow:-12px 0 30px rgba(0,0,0,.4); z-index:4; }
|
||||
.detailsbox.open{ transform:translateX(0); }
|
||||
#backdrop{ position:fixed; inset:var(--header-h) 0 0 0; background:rgba(0,0,0,.35); display:none; z-index:3; }
|
||||
#backdrop.open{ display:block; }
|
||||
#list.blur{ filter:blur(2px); }
|
||||
#backdrop::before{ content:"‹"; position:absolute; left:10px; top:50%; transform:translateY(-50%);
|
||||
font-size:48px; line-height:1; color:#e5e7eb; opacity:.8; }
|
||||
}
|
||||
|
||||
/* Anchored copy popover + backdrop */
|
||||
#copyBackdrop{ position:fixed; inset:0; display:none; z-index:20; background:transparent; }
|
||||
#copyBackdrop.open{ display:block; }
|
||||
#copyPopover{ position:fixed; display:none; z-index:21; background:var(--panel); border:1px solid #1f2937;
|
||||
border-radius:10px; padding:10px; min-width: min(420px, 90vw);
|
||||
box-shadow:0 18px 50px rgba(0,0,0,.45); }
|
||||
#copyPopover.open{ display:block; }
|
||||
#copyPopover .title{ font-weight:600; margin-bottom:6px; }
|
||||
#copyPopover pre{ margin:6px 0; white-space:pre-wrap; word-break:break-word; background:#0a1220; border:1px dashed #1f2937; padding:8px; border-radius:6px; }
|
||||
|
||||
/* Tiny toast */
|
||||
#toast{ position:fixed; left:50%; bottom:28px; transform:translateX(-50%); background:#111827; color:#e5e7eb;
|
||||
border:1px solid #1f2937; padding:6px 10px; border-radius:999px; font-size:12px; opacity:0; pointer-events:none; z-index:40; transition:opacity .18s ease; }
|
||||
#toast.show{ opacity:1; }
|
||||
|
||||
/* Bottom sheet (full command details) */
|
||||
#fullDetailsBackdrop{
|
||||
position:fixed; inset:var(--header-h) 0 0 0; display:none; z-index:12;
|
||||
background:rgba(0,0,0,.35);
|
||||
}
|
||||
#fullDetailsBackdrop.open{ display:block; }
|
||||
|
||||
#fullDetailsSheet{
|
||||
position:fixed; left:50%; transform:translate(-50%, 100%);
|
||||
bottom:0; width:min(1200px, 94vw); max-height:75vh; overflow:auto;
|
||||
background:var(--panel); border:1px solid #1f2937;
|
||||
border-radius:12px 12px 0 0; box-shadow:0 18px 50px rgba(0,0,0,.45);
|
||||
z-index:13; transition:transform .25s ease;
|
||||
}
|
||||
#fullDetailsSheet.open{ transform:translate(-50%, 0); }
|
||||
|
||||
#fullDetailsHead{
|
||||
position:sticky; top:0; background:rgba(0,0,0,.35);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding:10px 12px; border-bottom:1px solid #1f2937;
|
||||
}
|
||||
#fullDetailsTitle{ font-weight:600; }
|
||||
#fullDetailsBody{ padding:12px; }
|
||||
|
||||
/* Small inline flag in footer */
|
||||
.flag-emoji{
|
||||
height: 1em;
|
||||
width: auto;
|
||||
vertical-align: -0.18em;
|
||||
display: inline-block;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="hdr">
|
||||
<div class="title">__TITLE__</div>
|
||||
<div class="actions">
|
||||
<button id="openDiscord" class="btn btn-icon" title="Open Discord">💬</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Toolbar -->
|
||||
<div class="panel toolbar" id="toolbar">
|
||||
<input id="q" class="search" placeholder="Search name/description…">
|
||||
<div style="margin-top:8px; font-size:12px; color:var(--muted)">
|
||||
Sections: <a href="#user">User</a> · <a href="#moderator">Moderator</a> · <a href="#all">All</a>
|
||||
<span id="counts" style="margin-left:10px"></span>
|
||||
<span id="alerts" style="margin-left:10px; color:#fbbf24"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="veil" id="veil"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col"><div id="list" class="list"></div></div>
|
||||
<div class="col"><div class="panel detailsbox" id="details"></div></div>
|
||||
</div>
|
||||
|
||||
<footer id="footer">
|
||||
<div class="line" id="copyright"></div>
|
||||
<div class="line" id="statusline">Uptime: — · Version: v—</div>
|
||||
<div class="line" id="coffee" style="display:__SUPPORT_VIS__">
|
||||
<a id="supportLink" href="__SUPPORT_URL__" target="_blank" rel="noopener noreferrer">__SUPPORT_LABEL__</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Mobile details backdrop -->
|
||||
<div id="backdrop"></div>
|
||||
|
||||
<!-- Discord drawer -->
|
||||
<div id="discordBackdrop" class="drawer-backdrop"></div>
|
||||
<div id="discordSheet" class="drawer">
|
||||
<button class="btn btn-icon drawer-close" title="Close">✕</button>
|
||||
<iframe id="discordWidget"
|
||||
src="https://discord.com/widget?id=1396826999095427253&theme=dark"
|
||||
allowtransparency="true" frameborder="0"
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
|
||||
style="width:100%;height:100%;border:0"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Anchored copy popover -->
|
||||
<div id="copyBackdrop"></div>
|
||||
<div id="copyPopover" role="dialog" aria-modal="true">
|
||||
<div class="title">Copy to clipboard</div>
|
||||
<pre id="copyText">—</pre>
|
||||
<div class="btn-row">
|
||||
<button id="copyAction" class="btn">Copy</button>
|
||||
<button id="copyClose" class="btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom full-details sheet -->
|
||||
<div id="fullDetailsBackdrop"></div>
|
||||
<div id="fullDetailsSheet" role="dialog" aria-modal="true" aria-labelledby="fullDetailsTitle">
|
||||
<div id="fullDetailsHead">
|
||||
<div id="fullDetailsTitle">Command details</div>
|
||||
<span style="flex:1"></span>
|
||||
<button id="fullDetailsClose" class="btn btn-icon" title="Close">✕</button>
|
||||
</div>
|
||||
<div id="fullDetailsBody"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tiny toast -->
|
||||
<div id="toast" aria-live="polite">Copied!</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
/* Sticky offsets */
|
||||
function computeStickyTop(){
|
||||
const header=document.querySelector('header');
|
||||
const toolbar=document.getElementById('toolbar');
|
||||
const headerH=header?header.offsetHeight:0;
|
||||
document.documentElement.style.setProperty('--header-h', headerH+'px');
|
||||
const toolbarH=toolbar?toolbar.offsetHeight:0;
|
||||
const stickyTop=headerH+toolbarH+8;
|
||||
document.documentElement.style.setProperty('--sticky-top', stickyTop+'px');
|
||||
const veil=document.getElementById('veil');
|
||||
if(veil){ veil.style.top=headerH+'px'; veil.style.height='16px'; }
|
||||
}
|
||||
addEventListener('resize', computeStickyTop);
|
||||
addEventListener('load', computeStickyTop);
|
||||
|
||||
/* Error surface */
|
||||
addEventListener('error', e=>{ const el=document.getElementById('alerts'); if(el) el.textContent='JS error: '+(e?.message||''); });
|
||||
addEventListener('unhandledrejection', e=>{ const el=document.getElementById('alerts'); const msg=e&&(e.reason&&(e.reason.message||e.reason)||e); if(el) el.textContent='Promise error: '+msg; });
|
||||
|
||||
/* Mini MD (fallback when only .md exists) */
|
||||
function renderMD(src){ if(!src) return ""; let s=String(src).replace(/\r\n/g,"\n");
|
||||
s=s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
s=s.replace(/```([\s\S]*?)```/g,(m,p1)=>"<pre><code>"+p1+"</code></pre>");
|
||||
s=s.replace(/^###\s+(.*)$/gm,"<h3>$1</h3>");
|
||||
s=s.replace(/^##\s+(.*)$/gm,"<h2>$1</h2>");
|
||||
s=s.replace(/^#\s+(.*)$/gm,"<h1>$1</h1>");
|
||||
s=s.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>");
|
||||
s=s.replace(/\*(.+?)\*/g,"<em>$1</em>");
|
||||
s=s.replace(/`([^`]+?)`/g,"<code>$1</code>");
|
||||
s=s.replace(/^(\s*)-\s+(.+)$/gm,"$1<li>$2</li>");
|
||||
s=s.replace(/(<li>.*<\/li>\n?)+/g,m=>"<ul>"+m+"</ul>");
|
||||
s=s.replace(/^(?!<h\d|<ul>|<pre>|<li>)([^\n][^\n]*)$/gm,"<p>$1</p>");
|
||||
return s; }
|
||||
|
||||
/* Link helpers */
|
||||
function rowAnchor(r){ const cog=(r.cog||'nocog').toLowerCase();
|
||||
let base=(r.name||'').toLowerCase().replace(/^\//,'').replace(/\s+/g,'-').replace(/\//g,'-'); return `${cog}-${base}`; }
|
||||
function getFilterFromHash(){ const h=(location.hash||'#user').slice(1);
|
||||
const parts=h.split('&').map(s=>s.trim()); const filt=parts.find(p=>p==='user'||p==='moderator'||p==='all')||'user';
|
||||
let cmd=null; const cmdPart=parts.find(p=>p.startsWith('cmd=')); if(cmdPart) cmd=cmdPart.slice(4);
|
||||
const sp=new URLSearchParams(location.search); const qcmd=sp.get('cmd'); if(!cmd&&qcmd) cmd=qcmd; return {filter:filt, cmd}; }
|
||||
function buildLink(anchor){ const url=new URL(location.href); url.searchParams.set('cmd',anchor); url.hash=location.hash||'#user'; return url.toString(); }
|
||||
function replaceURLFor(anchor){ try{ const url=new URL(location.href); url.searchParams.set('cmd',anchor); history.replaceState(null,'',url.toString()); }catch{} }
|
||||
|
||||
/* Copy helpers */
|
||||
async function copyText(s){ try{ await navigator.clipboard.writeText(s); return true; }catch{ return false; } }
|
||||
function showToast(msg='Copied!'){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'), 1100); }
|
||||
|
||||
const copyBackdrop=document.getElementById('copyBackdrop');
|
||||
const copyPopover=document.getElementById('copyPopover');
|
||||
const copyTextEl=document.getElementById('copyText');
|
||||
const copyAction=document.getElementById('copyAction');
|
||||
const copyClose=document.getElementById('copyClose');
|
||||
|
||||
function closeCopyPopover(){ copyBackdrop.classList.remove('open'); copyPopover.classList.remove('open'); }
|
||||
copyBackdrop.addEventListener('click', closeCopyPopover);
|
||||
copyClose.addEventListener('click', closeCopyPopover);
|
||||
|
||||
function openCopyPopover(text, anchorEl){
|
||||
copyTextEl.textContent=text;
|
||||
copyAction.onclick=async ()=>{ const ok=await copyText(text); if(ok){ closeCopyPopover(); showToast(); } };
|
||||
|
||||
// Position: prefer above; keep inside viewport
|
||||
const r=anchorEl.getBoundingClientRect();
|
||||
const pad=8, vw=innerWidth, vh=innerHeight;
|
||||
copyPopover.style.visibility='hidden'; copyPopover.classList.add('open'); // measure
|
||||
const pw=copyPopover.offsetWidth, ph=copyPopover.offsetHeight;
|
||||
let left = Math.min(Math.max(pad, r.right - pw), vw - pw - pad);
|
||||
let top = r.top - ph - pad;
|
||||
if(top < (pad + 4)) top = Math.min(vh - ph - pad, r.bottom + pad);
|
||||
copyPopover.style.left = `${left}px`;
|
||||
copyPopover.style.top = `${top}px`;
|
||||
copyPopover.style.visibility='visible';
|
||||
copyBackdrop.classList.add('open');
|
||||
}
|
||||
|
||||
/* Bottom sheet */
|
||||
const fdb = document.getElementById('fullDetailsBackdrop');
|
||||
const fds = document.getElementById('fullDetailsSheet');
|
||||
const fdbtn = document.getElementById('fullDetailsClose');
|
||||
const fdbody = document.getElementById('fullDetailsBody');
|
||||
const fdtitle = document.getElementById('fullDetailsTitle');
|
||||
function openFullDetails(title, html){
|
||||
fdtitle.textContent = title || 'Command details';
|
||||
fdbody.innerHTML = html || '<p>No extra details.</p>';
|
||||
fdb.classList.add('open');
|
||||
fds.classList.add('open');
|
||||
}
|
||||
function closeFullDetails(){ fdb.classList.remove('open'); fds.classList.remove('open'); }
|
||||
fdb?.addEventListener('click', closeFullDetails);
|
||||
fdbtn?.addEventListener('click', closeFullDetails);
|
||||
|
||||
/* App */
|
||||
(function(){
|
||||
if(!location.hash) location.hash='#user';
|
||||
let data=(window.__DATA__||null);
|
||||
|
||||
const listEl=document.getElementById('list');
|
||||
const qEl=document.getElementById('q');
|
||||
const countsEl=document.getElementById('counts');
|
||||
const detailsEl=document.getElementById('details');
|
||||
const backdrop=document.getElementById('backdrop');
|
||||
|
||||
function shownName(r){ return (r.display_name||r.name||'').replace(/^\//,''); }
|
||||
function helpSansMod(r){ return (r.help||'').replace(/^\s*\[(MOD|ADMIN)\]\s*/i,''); }
|
||||
function moduleSansPrefix(r){ const m=r.module||''; return m.replace(/^modules?\./,'').replace(/^discord\.ext\./,''); }
|
||||
|
||||
async function shareFor(r){
|
||||
const anchor=rowAnchor(r);
|
||||
const url=buildLink(anchor);
|
||||
const ok=await copyText(url);
|
||||
if(ok) showToast('Link copied!');
|
||||
else openCopyPopover(url, document.querySelector(`#card-${anchor} [data-share]`) || document.getElementById('shareDetails') || document.body);
|
||||
}
|
||||
|
||||
function usageBlockHTML(text){
|
||||
if(!text) return '';
|
||||
const esc=String(text).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
return `<div class="usage">${esc}<button class="btn btn-icon copybtn" title="Copy usage" data-copy="${esc}">📋</button></div>`;
|
||||
}
|
||||
|
||||
function wireUsageCopy(container){
|
||||
container.querySelectorAll('[data-copy]').forEach(btn=>{
|
||||
btn.addEventListener('click', async ev=>{
|
||||
ev.stopPropagation();
|
||||
const t=btn.getAttribute('data-copy').replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&');
|
||||
const ok=await copyText(t);
|
||||
if(ok) showToast();
|
||||
else openCopyPopover(t, btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openDetails(r){
|
||||
const anchor=rowAnchor(r);
|
||||
replaceURLFor(anchor);
|
||||
let html=`
|
||||
<div class="name" style="margin-bottom:6px">
|
||||
<span class="pill ${r.type}">${r.type}</span>
|
||||
${r.moderator_only?'<span class="pill mod">mod</span>':''}
|
||||
${r.admin_only?'<span class="pill admin">admin</span>':''}
|
||||
<span>${shownName(r)}</span>
|
||||
<span style="flex:1"></span>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-icon" title="Copy link" id="shareDetails">🔗</button>
|
||||
<button class="btn" id="closeDetails" style="display:none">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
${r.cog?`<span>cog: ${r.cog}</span>`:''}
|
||||
${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
|
||||
<span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
|
||||
</div>
|
||||
${usageBlockHTML(r.usage_prefix)}
|
||||
${usageBlockHTML(r.usage_slash)}
|
||||
${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
|
||||
`;
|
||||
|
||||
// Brief/details HTML (or MD fallback)
|
||||
if (r.brief_html) {
|
||||
html += `<div class="md" style="margin-top:10px">${r.brief_html}</div>`;
|
||||
} else if (r.details_html) {
|
||||
html += `<div class="md" style="margin-top:10px">${r.details_html}</div>`;
|
||||
} else if (r.details_md) {
|
||||
html += `<div class="md" style="margin-top:10px">${renderMD(r.details_md)}</div>`;
|
||||
} else {
|
||||
html += `<div class="md" style="margin-top:10px"><p style="color:#9ca3af;font-size:14px;margin:0 0 6px 0">No custom documentation yet.</p></div>`;
|
||||
}
|
||||
|
||||
// "Open full details" when we have long content
|
||||
const hasFull = !!(r.details_html || r.details_md);
|
||||
if (hasFull) {
|
||||
html += `<div class="btn-row" style="margin-top:10px">
|
||||
<button id="openFullDetails" class="btn">Open full details</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
detailsEl.innerHTML=html;
|
||||
|
||||
const shareBtn=document.getElementById('shareDetails');
|
||||
if(shareBtn) shareBtn.onclick=(e)=>{ e.stopPropagation(); shareFor(r); };
|
||||
const fullBtn=document.getElementById('openFullDetails');
|
||||
if(fullBtn){
|
||||
fullBtn.onclick=(e)=>{
|
||||
e.stopPropagation();
|
||||
const longHtml = r.details_html ? r.details_html : renderMD(r.details_md || '');
|
||||
openFullDetails(shownName(r), longHtml);
|
||||
};
|
||||
}
|
||||
wireUsageCopy(detailsEl);
|
||||
|
||||
// Mobile sheet open
|
||||
if (matchMedia('(max-width: 900px)').matches){
|
||||
detailsEl.classList.add('open'); listEl.classList.add('blur'); backdrop.classList.add('open');
|
||||
const closeBtn=document.getElementById('closeDetails'); if(closeBtn) closeBtn.style.display='inline-block';
|
||||
backdrop.onclick=closeDetails; if(closeBtn) closeBtn.onclick=closeDetails;
|
||||
}
|
||||
}
|
||||
function closeDetails(){ detailsEl.classList.remove('open'); listEl.classList.remove('blur'); backdrop.classList.remove('open'); }
|
||||
|
||||
function card(r){
|
||||
const c=document.createElement('div'); const anchor=rowAnchor(r);
|
||||
c.className='card'; c.id='card-'+anchor; c.dataset.anchor=anchor;
|
||||
c.dataset.search=[shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase();
|
||||
|
||||
const usageHTML=(r.type==='hybrid') ? `${usageBlockHTML(r.usage_prefix)}${usageBlockHTML(r.usage_slash)}` : `${usageBlockHTML(r.usage)}`;
|
||||
|
||||
c.innerHTML=`
|
||||
<div class="name">
|
||||
<span class="pill ${r.type}">${r.type}</span>
|
||||
${r.moderator_only?'<span class="pill mod">mod</span>':''}
|
||||
${r.admin_only?'<span class="pill admin">admin</span>':''}
|
||||
<span>${shownName(r)}</span>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-icon" title="Copy link" data-share="1">🔗</button>
|
||||
<button class="btn" data-details="1">Details</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
${r.cog?`<span>cog: ${r.cog}</span>`:''}
|
||||
${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
|
||||
<span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
|
||||
</div>
|
||||
${usageHTML}
|
||||
${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
|
||||
`;
|
||||
c.querySelector('[data-details]').addEventListener('click', ev=>{ ev.stopPropagation(); openDetails(r); });
|
||||
c.querySelector('[data-share]').addEventListener('click', async ev=>{
|
||||
ev.stopPropagation();
|
||||
const url=buildLink(anchor);
|
||||
const ok=await copyText(url);
|
||||
if(ok) showToast('Link copied!');
|
||||
else openCopyPopover(url, ev.currentTarget);
|
||||
});
|
||||
c.addEventListener('click', ()=>openDetails(r));
|
||||
wireUsageCopy(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
function render(target, rows){ target.innerHTML=''; rows.forEach(r=>target.appendChild(card(r))); }
|
||||
|
||||
function applyFilter(selectAnchorIfPresent=true){
|
||||
if(!data) return;
|
||||
const all=data.all||[], mods=(data.sections&&data.sections.moderator)||[], users=(data.sections&&data.sections.user)||[];
|
||||
countsEl.textContent=`User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`;
|
||||
|
||||
const q=(qEl.value||'').toLowerCase();
|
||||
const {filter, cmd}=getFilterFromHash();
|
||||
const src=filter==='moderator'?mods:(filter==='all'?all:users);
|
||||
const rows=!q?src:src.filter(r=>([shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase().includes(q)));
|
||||
render(listEl, rows);
|
||||
|
||||
const isMobile = matchMedia('(max-width: 900px)').matches;
|
||||
|
||||
// Selection logic:
|
||||
// - If URL has an anchor, prefer that (scroll to it)
|
||||
// - Otherwise, ONLY auto-open first row on non-mobile
|
||||
let sel = null;
|
||||
|
||||
if (selectAnchorIfPresent && cmd) {
|
||||
const hit = rows.find(r => rowAnchor(r) === cmd);
|
||||
if (hit) sel = hit;
|
||||
const cardEl = document.getElementById('card-'+cmd);
|
||||
if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'});
|
||||
} else if (!isMobile && rows.length) {
|
||||
sel = rows[0];
|
||||
}
|
||||
|
||||
// Do not auto-open on mobile
|
||||
if (sel && !isMobile) openDetails(sel);
|
||||
}
|
||||
|
||||
async function boot(){
|
||||
computeStickyTop();
|
||||
try{
|
||||
async function loadData(){
|
||||
try{
|
||||
const res = await fetch('/api/commands', { cache:'no-store' });
|
||||
if(!res.ok) throw new Error('HTTP '+res.status);
|
||||
data = await res.json();
|
||||
return true; // fetched fresh
|
||||
}catch(e){
|
||||
// fallback to inline bootstrap if fetch fails
|
||||
if(window.__DATA__){ data = window.__DATA__; return false; }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener('hashchange', ()=>applyFilter(false));
|
||||
qEl.addEventListener('input', ()=>applyFilter(false));
|
||||
|
||||
await loadData(); // always refresh from backend
|
||||
applyFilter(true);
|
||||
}catch{ document.getElementById('alerts').textContent='Failed to load.'; }
|
||||
|
||||
// Footer
|
||||
const fromYear=2025, now=new Date();
|
||||
const flagSvg='<img class="flag-emoji" alt="NO" src="/assets/docs/no.svg">';
|
||||
document.getElementById('copyright').innerHTML=`© OokamiKunTV ${fromYear}–${now.getFullYear()} — Made in ${flagSvg} with ❤️`;
|
||||
try{
|
||||
const s=await fetch('/api/status',{cache:'no-store'});
|
||||
if(s.ok){ const js=await s.json();
|
||||
document.getElementById('statusline').textContent=`Uptime: ${fmtDuration(js.uptime_seconds||0)} · Version: v${js.version||'unknown'}`; }
|
||||
}catch{}
|
||||
|
||||
// Discord drawer
|
||||
const ds=document.getElementById('discordSheet');
|
||||
const db=document.getElementById('discordBackdrop');
|
||||
const openBtn=document.getElementById('openDiscord');
|
||||
const closeBtn=ds.querySelector('.drawer-close');
|
||||
const openDiscord=()=>{ ds.classList.add('open'); db.classList.add('open'); };
|
||||
const closeDiscord=()=>{ ds.classList.remove('open'); db.classList.remove('open'); };
|
||||
if(openBtn) openBtn.onclick=openDiscord; if(closeBtn) closeBtn.onclick=closeDiscord; if(db) db.onclick=closeDiscord;
|
||||
}
|
||||
|
||||
function fmtDuration(s){ s=Math.max(0,Math.floor(+s||0));
|
||||
const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); const sec=s%60;
|
||||
const parts=[]; if(d) parts.push(d+'d'); if(h||d) parts.push(h+'h'); if(m||h||d) parts.push(m+'m'); parts.push(sec+'s'); return parts.join(' '); }
|
||||
|
||||
setInterval(async ()=>{
|
||||
try{
|
||||
const gotFresh = await loadData();
|
||||
if(gotFresh) applyFilter(false);
|
||||
}catch{}
|
||||
}, 30000);
|
||||
boot();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -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>
|
23
assets/docs/commands/AutoVCCog.avc_cleanup_now.details.html
Normal 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>
|
8
assets/docs/commands/AutoVCCog.avc_renumber.brief.html
Normal 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>
|
19
assets/docs/commands/AutoVCCog.avc_renumber.details.html
Normal 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>
|
8
assets/docs/commands/AutoVCCog.avc_status.brief.html
Normal 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>
|
22
assets/docs/commands/AutoVCCog.avc_status.details.html
Normal 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 room’s name and whether it’s empty or how many are inside. If a room is empty, an “idle Ns” timer may appear.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Example output</h2>
|
||||
<pre style="white-space:pre-wrap;margin:.25rem 0 0">
|
||||
Auto-VC status:
|
||||
Trigger: <#1234567890> | Category: <#2345678901> | Prefix: `Room` | Delay: 30s
|
||||
- #1: Room 1 — 3 inside
|
||||
- #2: Room 2 — empty | idle 12s
|
||||
</pre>
|
||||
|
||||
<p style="margin-top:.75rem"><small>
|
||||
This command is read-only and safe to run anytime.
|
||||
</small></p>
|
22
assets/docs/commands/DDLootTableCog.dd_update.brief.html
Normal file
@ -0,0 +1,22 @@
|
||||
<h2>/dd_update</h2>
|
||||
<p><strong>Control the Deep Desert weekly loot updater.</strong> Stop/resume the scheduler or force a one-off start.</p>
|
||||
|
||||
<h3>Usage</h3>
|
||||
<pre>/dd_update <action> [reason]</pre>
|
||||
<ul>
|
||||
<li><code>action</code> — one of <code>stop</code>, <code>resume</code>, <code>start</code>.</li>
|
||||
<li><code>reason</code> (optional) — short note shown in the confirmation.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Permissions</h3>
|
||||
<ul>
|
||||
<li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>
|
||||
<li><strong>start</strong>: <em>Server Owner only</em> (dangerous; bypasses the usual wait).</li>
|
||||
</ul>
|
||||
|
||||
<h3>What it does</h3>
|
||||
<ul>
|
||||
<li><em>stop</em> — pauses all checks until <code>resume</code>.</li>
|
||||
<li><em>resume</em> — returns to the normal weekly cycle.</li>
|
||||
<li><em>start</em> — behaves as if the weekly reset just happened and begins polling immediately.</li>
|
||||
</ul>
|
50
assets/docs/commands/DDLootTableCog.dd_update.details.html
Normal file
@ -0,0 +1,50 @@
|
||||
<h1>/dd_update — Deep Desert updater controls</h1>
|
||||
<p>Manage the weekly “Deep Desert — Weekly Uniques” message updater for this guild.</p>
|
||||
|
||||
<h2>Usage</h2>
|
||||
<pre>/dd_update <action> [reason]</pre>
|
||||
<ul>
|
||||
<li><code>action</code> — <code>stop</code> | <code>resume</code> | <code>start</code></li>
|
||||
<li><code>reason</code> (optional) — free text appended to the confirmation response.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Permissions</h2>
|
||||
<ul>
|
||||
<li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>
|
||||
<li><strong>start</strong>: <em>Server Owner only</em>. Use with care—this forces an immediate check cycle.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Behavior</h2>
|
||||
<ul>
|
||||
<li><strong>Weekly reset:</strong> Tuesdays <strong>03:00 UTC</strong>. At reset, the bot updates the channel message to indicate it’s waiting for the new week.</li>
|
||||
<li><strong>Polling cadence:</strong> adaptive back-off until fresh data is found:
|
||||
<ul>
|
||||
<li>Every 5 min for the first hour</li>
|
||||
<li>Then every 15 min until 3 hours</li>
|
||||
<li>Then every 30 min until 6 hours</li>
|
||||
<li>Then every 1 hour until 24 hours</li>
|
||||
<li>Then every 3 hours</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Success:</strong> when new valid data appears, the bot updates the message once and idles until the next weekly reset.</li>
|
||||
<li><strong>Errors / no update yet:</strong> the message shows a generic notice that it’s still waiting or that an issue occurred (no external source is mentioned).</li>
|
||||
</ul>
|
||||
|
||||
<h2>Actions</h2>
|
||||
<ul>
|
||||
<li><strong>stop</strong> — Pauses all checks. The message remains as-is until you <code>resume</code>.</li>
|
||||
<li><strong>resume</strong> — Returns to the normal weekly cycle (detect reset, then poll until new data).</li>
|
||||
<li><strong>start</strong> — Pretends the weekly reset just happened and begins polling now. <em>Owner-only.</em></li>
|
||||
</ul>
|
||||
|
||||
<h2>Channel & config</h2>
|
||||
<ul>
|
||||
<li><strong>Target channel:</strong> taken from <code>SHAI_DD_CHANNEL_ID</code> (env). If unset, defaults to <code>1404764793377652807</code>.</li>
|
||||
<li><strong>Scope:</strong> guild-specific. The message lives in this server’s configured channel.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Notes</h2>
|
||||
<ul>
|
||||
<li>This command only controls the updater; it does not manually edit the posted message.</li>
|
||||
<li>On each successful weekly update, the bot compares content against the previous week to avoid duplicate posts.</li>
|
||||
</ul>
|
@ -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>
|
@ -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>
|
@ -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> user’s missing pending review.</p>
|
||||
|
||||
<div class="usage" style="margin-top:.5rem">/recreate_nick_review <b>user:</b> @Member</div>
|
||||
|
||||
<p style="margin-top:.6rem"><small>
|
||||
If they’re already verified or already have a pending review, you’ll get a short explanation instead (ephemeral).
|
||||
</small></p>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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 can’t find the message, it posts a new card and records the message ID.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Works serially per guild to avoid race conditions.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Output</h2>
|
||||
<p>Ephemeral summary, e.g.:</p>
|
||||
<pre>Rebuilt/updated 27 pirate cards.</pre>
|
||||
|
||||
<h2>Setup notes</h2>
|
||||
<ul>
|
||||
<li>Uses <code>pirates_list_channel_id</code> for the destination channel.</li>
|
||||
<li>Threat %, group/destructive buckets, and last seen are computed from stored encounters.</li>
|
||||
<li>Bucket thresholds respect your server settings (e.g., <code>threat_group_threshold</code>, <code>threat_min_samples_for_stats</code>).</li>
|
||||
</ul>
|
||||
|
||||
<h2>When to run it</h2>
|
||||
<ul>
|
||||
<li>After importing or migrating data.</li>
|
||||
<li>After a batch of new pirates is approved.</li>
|
||||
<li>Any time the cards look out of date.</li>
|
||||
</ul>
|
16
assets/docs/commands/PirateReportCog.edit_pirate.brief.html
Normal 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>
|
@ -0,0 +1,24 @@
|
||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
|
||||
<p>
|
||||
Updates a pirate record that’s already approved. Typical cases: the player renamed their character,
|
||||
or moved to a new account tag.
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Form fields</h2>
|
||||
<ul>
|
||||
<li><b>Current account</b> — must match exactly what’s in the list (e.g., <code>SomeUser#12345</code>).</li>
|
||||
<li><b>New in-game nickname</b> — optional; leave empty to keep the current one.</li>
|
||||
<li><b>New account</b> — optional; must be <code>Name#12345</code>. If the new value already exists on another pirate, it’s rejected.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Rules & feedback</h2>
|
||||
<ul>
|
||||
<li><b>Moderator-only.</b></li>
|
||||
<li>You must change at least one thing (nickname or account).</li>
|
||||
<li>On success you’ll see “✅ Pirate updated.” (ephemeral) and the public list refreshes.</li>
|
||||
<li>A short mod-log entry is posted for audit (“✏️ Edited pirate …”).</li>
|
||||
</ul>
|
21
assets/docs/commands/PirateReportCog.encounter.brief.html
Normal 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> — 0–5 (0 = unknown).</li>
|
||||
</ul>
|
||||
|
||||
<p><b>Rate limit:</b> you can report the <i>same</i> pirate once every <b>10 minutes</b>.</p>
|
||||
|
||||
<div style="margin-top:.75rem">
|
||||
<div class="usage">/encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4</div>
|
||||
<div class="usage" style="margin-top:.4rem">/encounter SandStalker · group 5 · kills 0 · destructive no · skill 2</div>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:.75rem"><small>After you submit, you’ll get a private “Encounter recorded” message and the pirate list updates shortly after.</small></p>
|
75
assets/docs/commands/PirateReportCog.encounter.details.html
Normal 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 pirate’s history. These entries feed the
|
||||
threat score so the pirate list reflects how dangerous someone actually is right now.
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Form fields</h2>
|
||||
<ul>
|
||||
<li><b>Pirate (name or account)</b><br>
|
||||
Best: <code>Name#12345</code>. If you only know the character name, it must match exactly and not be ambiguous.
|
||||
</li>
|
||||
<li><b>Group size</b><br>
|
||||
Integer <b>≥ 1</b>. How many pirates were in their group, including the reported pirate.
|
||||
</li>
|
||||
<li><b>Kills</b><br>
|
||||
Integer <b>≥ 0</b>. Use 0 for none/unknown.
|
||||
</li>
|
||||
<li><b>Destructive?</b><br>
|
||||
<code>yes</code>/<code>no</code>. “Yes” if a base/ornithopter was destroyed, etc.
|
||||
</li>
|
||||
<li><b>Perceived Skill</b><br>
|
||||
<b>0–5</b>. Use 0 if you’re unsure. 5 = cracked aim/sweaty movement, 1–2 = casual.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="usage" style="margin:.6rem 0 0">
|
||||
/encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Validation & messages</h2>
|
||||
<ul>
|
||||
<li>If the account format is wrong, you’ll be told what to fix (<code>Name#12345</code> ending with five digits).</li>
|
||||
<li>Character names must be an exact match. If multiple pirates share that name, you’ll be asked to use the account.</li>
|
||||
<li><b>Rate limit:</b> one report per user per pirate every <b>10 minutes</b>.</li>
|
||||
<li>You’ll see a private <b>“Encounter recorded”</b> confirmation on success.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">How threat is calculated (short)</h2>
|
||||
<p>
|
||||
Each encounter contributes to a weighted score (0–100):
|
||||
</p>
|
||||
<ul>
|
||||
<li><b>Kills</b>: higher if the pirate gets kills.</li>
|
||||
<li><b>Destructive</b>: higher if they destroy stuff.</li>
|
||||
<li><b>Group size</b>: higher if they roll with <code>group ≥ T</code> (T comes from config).</li>
|
||||
<li><b>Skill</b>: your 0–5 rating normalized and averaged across encounters.</li>
|
||||
</ul>
|
||||
<p>
|
||||
The weights are configurable (defaults: Kill 0.35, Destruction 0.30, Group 0.20, Skill 0.15). After you submit,
|
||||
the bot recomputes the pirate’s <b>threat level</b> and <b>encounter count</b>, then refreshes the list.
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Tips</h2>
|
||||
<ul>
|
||||
<li>Use the account (<code>Name#12345</code>) whenever possible — it guarantees the report hits the right person.</li>
|
||||
<li>If you can’t tell skill, leave it at 0. That’s fine.</li>
|
||||
<li>Group size includes the pirate you’re reporting.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Common errors</h2>
|
||||
<ul>
|
||||
<li><b>“No such pirate registered.”</b> — Ask a mod to add them, or submit a <code>/report</code> first.</li>
|
||||
<li><b>“Character name is ambiguous.”</b> — Use the account tag (<code>Name#12345</code>).</li>
|
||||
<li><b>“You can only report the same pirate once every 10 minutes.”</b> — Wait a bit and try again.</li>
|
||||
</ul>
|
@ -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>
|
@ -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), it’s counted as “already accounts”.</li>
|
||||
<li>If the character name maps to exactly one pirate, it’s updated to that pirate’s account.</li>
|
||||
<li>If there’s no pirate with that name, it’s counted as “not found”.</li>
|
||||
<li>If multiple pirates share the same nickname, it’s “ambiguous” and skipped.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Result</h2>
|
||||
<ul>
|
||||
<li>Replies with a compact summary (updated / already / ambiguous / not found).</li>
|
||||
<li>Refreshes the public pirate list afterwards.</li>
|
||||
</ul>
|
||||
|
||||
<p style="margin-top:.6rem"><small>
|
||||
<b>Moderator-only.</b>
|
||||
</small></p>
|
@ -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: <code>Name#12345</code></div>
|
||||
|
||||
<p style="margin-top:.6rem"><small>
|
||||
Account format must be <code>Name#12345</code>. If the account isn’t found, you’ll get “Pirate not found.”
|
||||
</small></p>
|
@ -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>
|
24
assets/docs/commands/PirateReportCog.report.brief.html
Normal 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>
|
48
assets/docs/commands/PirateReportCog.report.details.html
Normal 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 it’s a video, the URL is kept above the card so the inline player works.</li>
|
||||
<li>A “Jump to message” button lets mods see your original context quickly.</li>
|
||||
<li>When a mod decides, the card updates to show <em>Approved/Rejected</em> with who and when.
|
||||
Your small ack message in the channel is edited to reflect the result.</li>
|
||||
<li>If approved, the player is added to the pirate list (threat level starts at 0).</li>
|
||||
</ol>
|
||||
|
||||
<h3>Rules & common errors</h3>
|
||||
<ul>
|
||||
<li><strong>Account format</strong> must be <code>Name#12345</code>. Five digits – no spaces at the end.</li>
|
||||
<li><strong>Proof link</strong> must be a Discord media URL. Non-Discord links are blocked.</li>
|
||||
<li><strong>Duplicates</strong> are blocked (already pending or already approved).</li>
|
||||
<li><strong>Rate limit</strong>: one report per user every 60 seconds.</li>
|
||||
</ul>
|
||||
<p style="color:#9ca3af">
|
||||
Typical messages: “Invalid account format”, “URL must be a Discord media link”, “A report for this player is already pending”.
|
||||
</p>
|
||||
|
||||
<h3>Related actions</h3>
|
||||
<ul>
|
||||
<li><code>/encounter</code> – log a new encounter with a known pirate (updates threat level over time).</li>
|
||||
<li><code>/edit_pirate</code> (mods) – update a pirate’s nickname/account.</li>
|
||||
<li><code>remove_pirate</code> (mods, hybrid) – remove an entry.</li>
|
||||
</ul>
|
@ -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>
|
@ -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 Discord’s 2000-character limit; old chunks are edited or deleted as needed.</li>
|
||||
<li>No one gets pinged — all content is sanitized to avoid accidental mentions.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Entry format</h2>
|
||||
<pre style="margin:.5rem 0;white-space:pre-wrap">
|
||||
- <b>Character</b> (<i>Account#12345</i>) [Threat%]
|
||||
- In group: <i>bucket</i>. Destructive: <i>bucket</i>. Encounters: N. Last: <t:UNIX:R>
|
||||
</pre>
|
||||
<ul>
|
||||
<li><b>Threat%</b> reflects combined encounter signals (kills, destructive behavior, group size, skill).</li>
|
||||
<li><b>In group / Destructive</b> use buckets: <code>unknown</code>, <code>never</code>, <code>rarely</code>, <code>sometimes</code>, <code>often</code>, <code>always</code>.</li>
|
||||
<li><b>Last</b> shows the most recent encounter as a relative timestamp.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
|
||||
<ul>
|
||||
<li>After a new pirate is approved or removed.</li>
|
||||
<li>After logging encounters that meaningfully change threat or buckets.</li>
|
||||
<li>When the list looks stale or out of order.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Behavior & notes</h2>
|
||||
<ul>
|
||||
<li>Sorts by character name, then account.</li>
|
||||
<li>If there are no pirates yet, shows a single placeholder line.</li>
|
||||
<li>If the list channel isn’t configured or can’t be found, nothing is posted (check your config).</li>
|
||||
<li>Per-guild cooldown: 10 seconds (prevents spam).</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Config that affects output</h2>
|
||||
<ul>
|
||||
<li><code>pirates_list_channel_id</code> — where the list is posted.</li>
|
||||
<li><code>threat_group_threshold</code> — minimum party size that counts as “in group”.</li>
|
||||
<li><code>threat_min_samples_for_stats</code> — encounters needed before buckets stop showing <code>unknown</code>.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Permissions</h2>
|
||||
<ul>
|
||||
<li><b>Moderator-only.</b></li>
|
||||
<li>Bot needs permission to read and write in the list channel.</li>
|
||||
</ul>
|
10
assets/docs/commands/PowerActionsCog.restart.brief.html
Normal 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>
|
61
assets/docs/commands/PowerActionsCog.restart.details.html
Normal file
@ -0,0 +1,61 @@
|
||||
<h2 style="margin:0 0 .5rem 0">What it does</h2>
|
||||
<ul>
|
||||
<li>Checks you’re a moderator, validates your reason, and posts a restart entry to the modlog (who, when, version, reason).</li>
|
||||
<li>Closes the bot cleanly and exits; your process manager/container brings it back up.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Reason requirements</h2>
|
||||
<p>The <code>reason</code> must be specific enough to audit later:</p>
|
||||
<ul>
|
||||
<li>At least <b>20 characters</b></li>
|
||||
<li>At least <b>4 words</b></li>
|
||||
<li>At least <b>3 words</b> with length ≥ 3</li>
|
||||
<li>Rejects vague phrases like: <code>stuck</code>, <code>idk</code>, <code>don't know</code>, <code>unknown</code>, <code>?</code>,
|
||||
<code>lag</code>, <code>restart</code>, <code>restarting</code>, <code>update</code>, <code>updating</code>,
|
||||
<code>bug</code>, <code>crash</code>, <code>crashed</code></li>
|
||||
</ul>
|
||||
|
||||
<h3 style="margin:.75rem 0 .4rem">Examples</h3>
|
||||
<div style="display:grid; gap:.5rem">
|
||||
<div>
|
||||
<div style="font-weight:600">✅ Good</div>
|
||||
<pre style="margin:.25rem 0;white-space:pre-wrap">/power restart reason:"Reloading cogs after changing threat weights and enabling nick loop; avoids inconsistent state."</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600">❌ Bad</div>
|
||||
<pre style="margin:.25rem 0;white-space:pre-wrap">/power restart reason:"update"</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">What you’ll see</h2>
|
||||
<ul>
|
||||
<li>Ephemeral confirmation in chat.</li>
|
||||
<li>Modlog post similar to:
|
||||
<pre style="margin:.4rem 0;white-space:pre-wrap">🔁 Bot Restart Requested
|
||||
By: @YourName
|
||||
When: 2025-01-01 12:34 UTC
|
||||
Running version: vX.Y.Z
|
||||
Reason: Reloading cogs after threat weight change…</pre>
|
||||
</li>
|
||||
<li>Bot goes offline briefly, then comes back once the host restarts it.</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Config that affects it</h2>
|
||||
<ul>
|
||||
<li><code>modlog_channel_id</code> — where the restart entry is posted. If not set, it falls back to server logs only.</li>
|
||||
<li><code>home_guild_id</code> — where the <code>/power</code> group is registered (home-guild only vs global).</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 style="margin:.75rem 0 .5rem">Permissions</h2>
|
||||
<ul>
|
||||
<li><b>Moderator-only.</b> Non-mods are blocked with an ephemeral message.</li>
|
||||
<li>Bot just needs permission to send messages in the modlog channel.</li>
|
||||
</ul>
|
18
assets/docs/commands/ReactionRoleCog.nick_same.brief.html
Normal 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 haven’t set one) already matches your in-game name, and opens a quick review.</p>
|
||||
|
||||
<h3>Usage</h3>
|
||||
<pre>/nick_same</pre>
|
||||
|
||||
<ul>
|
||||
<li>You’ll get an <em>ephemeral</em> “thanks” message.</li>
|
||||
<li>Moderators get a review with ✅ / ❌ in the mod channel.</li>
|
||||
<li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + this nickname claim (pending or verified).</li>
|
||||
</ul>
|
||||
|
||||
<h3>Tips</h3>
|
||||
<ul>
|
||||
<li>Run it in the server (not in DMs).</li>
|
||||
<li>If you recently changed your nick, this will trigger a re-review.</li>
|
||||
<li>Alternative: react ✅ on the “Nickname” rules message to claim the same thing.</li>
|
||||
</ul>
|
38
assets/docs/commands/ReactionRoleCog.nick_same.details.html
Normal 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>You’ll see a short <em>ephemeral</em> confirmation in the channel.</li>
|
||||
<li>Moderators get a message in the mod channel with ✅ Approve / ❌ Reject.</li>
|
||||
<li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + Nickname <em>claimed</em> (pending or verified).</li>
|
||||
</ul>
|
||||
|
||||
<h2>How to use</h2>
|
||||
<pre>/nick_same</pre>
|
||||
<ul>
|
||||
<li>Run it in the server, any channel where you can use slash commands.</li>
|
||||
<li>No extra arguments. If your display name already matches the game name, that’s enough.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Typical flow</h2>
|
||||
<ol>
|
||||
<li><strong>You</strong> run <code>/nick_same</code>. The bot replies “Thanks — your nickname claim was sent for moderator review.”</li>
|
||||
<li><strong>Mods</strong> see a review card with your previous nick (if known) and the current one.</li>
|
||||
<li>They press ✅ to verify or ❌ to reject. You keep (or lose) Full Access depending on Rules/Engagement and the result.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Good to know</h2>
|
||||
<ul>
|
||||
<li>If a verified user changes their nickname later, verification is <strong>automatically revoked</strong>. Just run <code>/nick_same</code> again (or react ✅ on the nickname message) to re-claim and re-review.</li>
|
||||
<li>The command is idempotent: if a review is already pending, it won’t open duplicates.</li>
|
||||
<li>Alternative path: reacting ✅ on the <em>Nickname</em> rules message triggers the same review and access checks.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Troubleshooting</h2>
|
||||
<ul>
|
||||
<li><strong>No response?</strong> Make sure you used it inside the server (not DMs) and that slash commands are allowed in the channel.</li>
|
||||
<li><strong>Still no Full Access?</strong> You need all three: Rules ✅, Engagement ✅, and a nickname claim (pending or verified). Missing any one will remove the role.</li>
|
||||
<li><strong>Mods didn’t get a review?</strong> Ping a moderator; the mod channel might be misconfigured.</li>
|
||||
</ul>
|
25
assets/docs/commands/SpicePayCog.spicepay.brief.html
Normal 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 (1–25).</li>
|
||||
<li><code>force_new</code> (optional): start fresh if you already have a session.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Quick flow</h3>
|
||||
<ol>
|
||||
<li>Enter the total yield and participant count.</li>
|
||||
<li>Add/edit each participant (name, active %, owner roles).</li>
|
||||
<li>Toggle payout: Sand ⟷ Melange (set refinery yield for Melange).</li>
|
||||
<li>Adjust weighting factors or use a preset.</li>
|
||||
<li>Finish → preview → Post to channel.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Notes</h3>
|
||||
<ul>
|
||||
<li>0% active = owner-only (gets owner perks but didn’t actively join).</li>
|
||||
<li>Melange: exactly one <em>Refiner</em> owner is required.</li>
|
||||
<li>Rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>
|
||||
</ul>
|
72
assets/docs/commands/SpicePayCog.spicepay.details.html
Normal 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 (1–25).</li>
|
||||
<li>Use <code>force_new: true</code> to discard an existing session and start clean.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Setup modal</h2>
|
||||
<ul>
|
||||
<li><strong>Total spice yield (sand)</strong> — integer ≥ 0.</li>
|
||||
<li><strong>Participants</strong> — include <em>owners</em> (Refiner/Carrier/Crawler). People only; vehicles/refinery are <em>owned</em> by people.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Editing participants</h2>
|
||||
<p>For each slot:</p>
|
||||
<ul>
|
||||
<li><strong>Name</strong> — free text (mention text is fine).</li>
|
||||
<li><strong>Active %</strong> — 0–100. <em>0% = owner-only</em> (didn’t actively join).</li>
|
||||
<li><strong>Owner of</strong> (optional) — any of: <code>refiner</code>, <code>carrier</code>, <code>crawler</code>. Flexible input (e.g. “lsr”, “refinery”, “car”, “cr”).</li>
|
||||
</ul>
|
||||
<p>Use <em>Add / Edit participant</em>, <em>Previous/Next</em> to navigate. The preview shows filled vs. empty slots and highlights “owner-only”.</p>
|
||||
|
||||
<h2>Payout type</h2>
|
||||
<ul>
|
||||
<li><strong>Sand</strong> — no refinery cut; 0 or 1 Refiner owner allowed.</li>
|
||||
<li><strong>Melange</strong> — <em>requires exactly one</em> Refiner owner. First a refinery cut % is taken and paid to the Refiner, then the rest is split.</li>
|
||||
</ul>
|
||||
<p>When switching to <strong>Melange</strong>, set the refinery yield (integer) in the modal.</p>
|
||||
|
||||
<h2>Weighting (the math, simplified)</h2>
|
||||
<ul>
|
||||
<li>Each person gets a <em>weight</em>: <strong>Base × Active%</strong> + bonuses for owning <strong>Carrier</strong>/<strong>Crawler</strong>.</li>
|
||||
<li>We split the pot <em>proportionally</em> by weights (they’re normalized; only ratios matter).</li>
|
||||
<li>Melange only: take <strong>Refinery cut %</strong> first → Refiner. Any rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>
|
||||
</ul>
|
||||
|
||||
<h3>Controls</h3>
|
||||
<ul>
|
||||
<li><em>Toggle payout: Sand/Melange</em> — switches mode (prompts for refinery yield on Melange).</li>
|
||||
<li><em>Adjust weighting factors</em> — change:
|
||||
<ul>
|
||||
<li><strong>Base × active %</strong> (default <em>server setting</em>)</li>
|
||||
<li><strong>Carrier bonus</strong> ( + )</li>
|
||||
<li><strong>Crawler bonus</strong> ( + )</li>
|
||||
<li><strong>Refinery cut %</strong> (Melange only)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><em>Weights help</em> — short explanation inline.</li>
|
||||
<li><em>Presets</em> — Owner-heavy / Participation-heavy / Fair (server defaults) / Even split.</li>
|
||||
<li><em>Save as my defaults</em> — stores your weights for future runs (per user).</li>
|
||||
<li><em>Finish</em> — shows a neat preview (table) and lets you <em>Post to channel</em>.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Validation & limits</h2>
|
||||
<ul>
|
||||
<li>Participants: 1–25.</li>
|
||||
<li>Active %: 0–100.</li>
|
||||
<li>Melange: exactly one Refiner owner required; Sand: 0 or 1 allowed.</li>
|
||||
<li>No duplicate names (duplicates are auto-disambiguated in preview).</li>
|
||||
</ul>
|
||||
|
||||
<h2>Posting</h2>
|
||||
<p>The post includes:</p>
|
||||
<ul>
|
||||
<li>Header (Sand or Melange) with the chosen emoji.</li>
|
||||
<li>Weighting summary (Base, bonuses, and refinery cut if Melange).</li>
|
||||
<li>A clean monospace table: Name · Active% · Owner of/Role · Amount.</li>
|
||||
</ul>
|
||||
<p><em>Footnote:</em> “0% = owner only”.</p>
|
10
assets/docs/commands/SpicePayCog.spicepay_cancel.brief.html
Normal 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>
|
@ -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>
|
10
assets/docs/commands/SpicePayCog.spicepay_config.brief.html
Normal 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>
|
@ -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 player’s active participation.</li>
|
||||
<li><strong>Carrier bonus</strong> — flat weight added if the person owns the carrier.</li>
|
||||
<li><strong>Crawler bonus</strong> — flat weight added if the person owns the crawler.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Where to change</h2>
|
||||
<p>Configure via environment variables or your INI, then restart the bot. The wizard’s <em>Adjust weighting factors</em> lets users override per-run; those don’t change the server defaults.</p>
|
||||
|
||||
<h2>Tips</h2>
|
||||
<ul>
|
||||
<li>Use <em>Presets</em> in the wizard to quickly switch philosophy (owner-heavy, participation-heavy, etc.).</li>
|
||||
<li>Users can <em>Save as my defaults</em> to keep their personal weights for future runs.</li>
|
||||
</ul>
|
10
assets/docs/commands/SpicePayCog.spicepay_resume.brief.html
Normal 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, you’ll be told to run <code>/spicepay</code>.</li>
|
||||
</ul>
|
@ -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>You’re mid-entry and don’t want to start over.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Behavior</h2>
|
||||
<ul>
|
||||
<li>If a session exists (keyed to you + this server), it re-renders the progress view.</li>
|
||||
<li>If none exists, you get a friendly nudge to start a new one.</li>
|
||||
</ul>
|
@ -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 & RoE roles to match reactions.</li>
|
||||
<li>For Nickname: opens a pending review if someone claimed but no review exists.</li>
|
||||
<li>Rebuilds/updates every user’s status card in the list channel.</li>
|
||||
</ul>
|
@ -0,0 +1,47 @@
|
||||
<h1>/usercards_rescan — Reconcile & refresh all cards</h1>
|
||||
<p>One-shot maintenance pass that makes the server’s user cards match reality.</p>
|
||||
|
||||
<h2>Access</h2>
|
||||
<ul>
|
||||
<li><strong>Moderator-only</strong> — requires the Discord permission <em>Manage Server</em>.</li>
|
||||
<li>Must be used in a server channel (not DMs). The result is sent <em>ephemerally</em>.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What it fixes</h2>
|
||||
<ol>
|
||||
<li><strong>Rules / RoE agreement</strong>
|
||||
<ul>
|
||||
<liReads reactions from your configured Rules and RoE messages.</li>
|
||||
<li>Adds/removes the corresponding roles so roles match the reactions.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Nickname claim & reviews</strong>
|
||||
<ul>
|
||||
<li>If a member has an “accept” reaction on the Nickname message but has <em>no</em> pending/verified record and <em>no</em> open review, it opens a <em>pending nickname review</em> for them.</li>
|
||||
<li>If a member <em>removed</em> their Nickname reaction, it clears any pending/verified state.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>User cards</strong>
|
||||
<ul>
|
||||
<li>Updates (or recreates) the embed for each member in the configured “users list” channel.</li>
|
||||
<li>Card color reflects: Rules, RoE, and Nickname status (✅ verified / ✔️ pending / ❌ not done).</li>
|
||||
<li>Uses a stable footer marker (<code>UID:<id></code>) to find/edit the right card; cleans up duplicates.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h2>Expected output</h2>
|
||||
<p>The command replies (ephemeral) with counts like:</p>
|
||||
<pre>Reconciled from messages. Changes — Rules: 3, RoE: 2, Nickname (added): 1, Nickname (removed): 0. Refreshed cards for 154 members.</pre>
|
||||
|
||||
<h2>Setup notes</h2>
|
||||
<ul>
|
||||
<li>Relies on your configured IDs (ENV/INI): Rules/RoE/Nickname message IDs and their role IDs, the Full Access role, the user-cards channel, and the mod/modlog channels.</li>
|
||||
<li>Won’t ping anyone; all posts/edits are sent with <em>no mentions</em>.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Tips</h2>
|
||||
<ul>
|
||||
<li>Run after importing a server, restoring from backup, or after downtime.</li>
|
||||
<li>Large servers: this may take a moment while it walks members and edits cards. It’s safe to run again.</li>
|
||||
</ul>
|
95
assets/docs/commands/__commands__.json
Normal file
@ -0,0 +1,95 @@
|
||||
{
|
||||
"PirateReportCog.report": {
|
||||
"details_md": "## What it does\nSubmit a player to the **Pirate Review Queue**. Mods get a compact card with the details and can approve ❇️ or reject ❌ with one click. If you include proof, it shows inline so they can decide faster.\n\n---\n\n## How to use — `/report`\nWhen you run **/report**, a small form opens:\n\n- **In-game nickname** \n The name they use in game. Example: `SandStalker`\n\n- **Account (Name#12345)** \n Must end with a `#` and **five digits**. Example: `SomeUser#12345` \n > If you only know the nickname, try to get the account tag too. It removes ambiguity later.\n\n- **Proof (Discord media URL — optional but strongly encouraged)** \n Paste a **direct Discord CDN** link so it previews nicely:\n - Accepts: `https://cdn.discordapp.com/...` or `https://media.discordapp.net/...`\n - Works with: **png, jpg, jpeg, gif, webp, mp4, webm, mov**\n - How to get the link: open the image/video in Discord ➜ “Open in browser” ➜ copy the address.\n\n> **Tip:** Images appear inside the mod card. Videos are kept as a plain link above the card so the inline video player works.\n\n---\n\n## What happens after you submit\n- You get a quick **thank-you** in the channel.\n- Mods receive an embed in their review channel with ✅ and ❌.\n- There’s a **“Jump to message”** button on the mod card so they can see the original context.\n- When a mod picks ✅/❌, the card is updated to show **Approved/Rejected** (with who decided and when). The small ack message in your channel is also edited to reflect the result.\n\nIf approved, the player is added to the pirate list (threat level starts at 0 and grows with encounters).\n\n---\n\n## Rules & limits (so you don’t run into errors)\n- **Account format:** must be `Name#12345`. Five digits. No spaces at the end.\n- **Proof link:** must be a Discord media link (see above). Non-Discord links are blocked.\n- **Duplicates:** already-approved players can’t be re-reported; exact pending duplicates are blocked too.\n- **Rate limit:** one report per user every **60 seconds**.\n\nCommon messages you might see:\n- `Invalid account format` → fix to `Name#12345`.\n- `URL must be a Discord media link` → use cdn.discordapp.com or media.discordapp.net.\n- `A report for this player is already pending` → a mod is already reviewing one.\n- `This player is already in the pirate list` → no need to report again.\n\n---\n\n## Good proof examples\n- Screenshot of **names** and actions in the **event log**.\n- Short clip (10–30s) that shows **what happened** and **who did it**.\n- If the situation is complex, add a quick text summary when you submit.\n\n---\n\n## Related actions\n- **/encounter** — log a new encounter with a known pirate. \n Fields:\n - *Pirate (name or account)* — `MuadDib` or `MuadDib#12345` (account preferred)\n - *Group size* — integer ≥ 1\n - *Kills* — integer ≥ 0 (0 = none/unknown)\n - *Destructive?* — `yes` / `no`\n - *Perceived Skill* — 0–5 (0 = unknown) \n Limits: one encounter per same pirate per reporter every **10 minutes**. These entries automatically update the pirate’s **threat level** and **encounter count**.\n\n- **/edit_pirate** *(moderators)* — update a pirate’s nickname/account.\n- **remove_pirate** *(moderators, hybrid)* — remove an approved entry.\n- **encounters_migrate_ids** *(moderators, hybrid)* — migrate old encounter identifiers.\n\n---\n\n## FAQ\n**Q: My proof link won’t accept.** \nA: It must be a direct Discord CDN URL (ends in a known image/video extension). Open the media in browser from Discord and copy that address.\n\n**Q: I only know their nickname and it says “ambiguous”.** \nA: Use the **account** form `Name#12345`. Nicknames can be shared.\n\n**Q: Do I have to add proof?** \nA: Optional, but it **really** helps mods decide quickly. Screenshots are usually enough.\n",
|
||||
"brief_html": "<h3 style=\"margin:0 0 4px 0;\">What it does</h3>\n<p style=\"margin:0 0 8px 0;\">\n Sends a player to the <strong>Pirate Review Queue</strong>. Mods get a card with ✅/❌.\n If you include a Discord media link, it will preview for them.\n</p>\n\n<h4 style=\"margin:10px 0 4px 0;\">Quick use — <code>/report</code></h4>\n<ul style=\"margin:0 0 8px 18px;\">\n <li><strong>In-game nickname</strong> – e.g. <code>SandStalker</code></li>\n <li><strong>Account</strong> – must be <code>Name#12345</code> (five digits)</li>\n <li><strong>Proof</strong> (optional, recommended) – direct Discord CDN link\n (<code>cdn.discordapp.com</code> / <code>media.discordapp.net</code>)</li>\n</ul>\n\n<h4 style=\"margin:10px 0 4px 0;\">Limits</h4>\n<ul style=\"margin:0 0 6px 18px;\">\n <li>1 submission per user every 60s</li>\n <li>No duplicates (pending or already approved)</li>\n <li>Non-Discord media links are blocked</li>\n</ul>\n\n<p style=\"margin:6px 0 0 0; color:#9ca3af;\">\n Tip: Images show inside the card; videos stay as a link so the inline player works.\n</p>\n",
|
||||
"details_html": "<h2 style=\"margin-top:0;\">Submit a pirate report</h2>\n<p>\n Use <code>/report</code> to send a player to the <strong>Pirate Review Queue</strong>.\n Mods see a compact card with your info and can approve or reject with one click.\n</p>\n\n<h3>Form fields</h3>\n<ul>\n <li><strong>In-game nickname</strong><br>\n The name they use in game. Example: <code>SandStalker</code></li>\n <li><strong>Account (Name#12345)</strong><br>\n Must end with <code>#</code> + <strong>five digits</strong>. Example: <code>SomeUser#12345</code><br>\n If you only know the nickname, try to grab the account too – it prevents ambiguity.</li>\n <li><strong>Proof (Discord media URL)</strong> – optional but highly encouraged<br>\n Use a direct Discord CDN link so it previews: <code>https://cdn.discordapp.com/…</code> or\n <code>https://media.discordapp.net/…</code><br>\n Allowed: <em>png, jpg, jpeg, gif, webp, mp4, webm, mov</em>.<br>\n How to get it: open the media in Discord → “Open in browser” → copy the address.</li>\n</ul>\n\n<h3>What happens next</h3>\n<ol>\n <li>You get a short “thanks” message in the channel.</li>\n <li>Mods receive an embed with ✅/❌. If you added an image, it shows inside the card.\n If it’s a video, the URL is kept above the card so the inline player works.</li>\n <li>A “Jump to message” button lets mods see your original context quickly.</li>\n <li>When a mod decides, the card updates to show <em>Approved/Rejected</em> with who and when.\n Your small ack message in the channel is edited to reflect the result.</li>\n <li>If approved, the player is added to the pirate list (threat level starts at 0).</li>\n</ol>\n\n<h3>Rules & common errors</h3>\n<ul>\n <li><strong>Account format</strong> must be <code>Name#12345</code>. Five digits – no spaces at the end.</li>\n <li><strong>Proof link</strong> must be a Discord media URL. Non-Discord links are blocked.</li>\n <li><strong>Duplicates</strong> are blocked (already pending or already approved).</li>\n <li><strong>Rate limit</strong>: one report per user every 60 seconds.</li>\n</ul>\n<p style=\"color:#9ca3af\">\n Typical messages: “Invalid account format”, “URL must be a Discord media link”, “A report for this player is already pending”.\n</p>\n\n<h3>Related actions</h3>\n<ul>\n <li><code>/encounter</code> – log a new encounter with a known pirate (updates threat level over time).</li>\n <li><code>/edit_pirate</code> (mods) – update a pirate’s nickname/account.</li>\n <li><code>remove_pirate</code> (mods, hybrid) – remove an entry.</li>\n</ul>\n"
|
||||
},
|
||||
"PirateReportCog.encounter": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/encounter</code></h2>\n\n<p>Log a pirate encounter so threat scores stay fresh. Takes ~10 seconds.</p>\n\n<ul>\n <li><b>Pirate</b> — account (<code>Name#12345</code>) or exact character name.<br>\n <small>Account is best. Character name must be unambiguous.</small></li>\n <li><b>Group size</b> — integer ≥ 1 (how many pirates in that group, including them).</li>\n <li><b>Kills</b> — integer ≥ 0 (0 if none/unknown).</li>\n <li><b>Destructive?</b> — yes/no (e.g., base or ornithopter destroyed).</li>\n <li><b>Perceived Skill</b> — 0–5 (0 = unknown).</li>\n</ul>\n\n<p><b>Rate limit:</b> you can report the <i>same</i> pirate once every <b>10 minutes</b>.</p>\n\n<div style=\"margin-top:.75rem\">\n <div class=\"usage\">/encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4</div>\n <div class=\"usage\" style=\"margin-top:.4rem\">/encounter SandStalker · group 5 · kills 0 · destructive no · skill 2</div>\n</div>\n\n<p style=\"margin-top:.75rem\"><small>After you submit, you’ll get a private “Encounter recorded” message and the pirate list updates shortly after.</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>\n <code>/encounter</code> adds a single encounter to the pirate’s history. These entries feed the\n threat score so the pirate list reflects how dangerous someone actually is right now.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Form fields</h2>\n<ul>\n <li><b>Pirate (name or account)</b><br>\n Best: <code>Name#12345</code>. If you only know the character name, it must match exactly and not be ambiguous.\n </li>\n <li><b>Group size</b><br>\n Integer <b>≥ 1</b>. How many pirates were in their group, including the reported pirate.\n </li>\n <li><b>Kills</b><br>\n Integer <b>≥ 0</b>. Use 0 for none/unknown.\n </li>\n <li><b>Destructive?</b><br>\n <code>yes</code>/<code>no</code>. “Yes” if a base/ornithopter was destroyed, etc.\n </li>\n <li><b>Perceived Skill</b><br>\n <b>0–5</b>. Use 0 if you’re unsure. 5 = cracked aim/sweaty movement, 1–2 = casual.\n </li>\n</ul>\n\n<div class=\"usage\" style=\"margin:.6rem 0 0\">\n /encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4\n</div>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Validation & messages</h2>\n<ul>\n <li>If the account format is wrong, you’ll be told what to fix (<code>Name#12345</code> ending with five digits).</li>\n <li>Character names must be an exact match. If multiple pirates share that name, you’ll be asked to use the account.</li>\n <li><b>Rate limit:</b> one report per user per pirate every <b>10 minutes</b>.</li>\n <li>You’ll see a private <b>“Encounter recorded”</b> confirmation on success.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">How threat is calculated (short)</h2>\n<p>\n Each encounter contributes to a weighted score (0–100):\n</p>\n<ul>\n <li><b>Kills</b>: higher if the pirate gets kills.</li>\n <li><b>Destructive</b>: higher if they destroy stuff.</li>\n <li><b>Group size</b>: higher if they roll with <code>group ≥ T</code> (T comes from config).</li>\n <li><b>Skill</b>: your 0–5 rating normalized and averaged across encounters.</li>\n</ul>\n<p>\n The weights are configurable (defaults: Kill 0.35, Destruction 0.30, Group 0.20, Skill 0.15). After you submit,\n the bot recomputes the pirate’s <b>threat level</b> and <b>encounter count</b>, then refreshes the list.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Tips</h2>\n<ul>\n <li>Use the account (<code>Name#12345</code>) whenever possible — it guarantees the report hits the right person.</li>\n <li>If you can’t tell skill, leave it at 0. That’s fine.</li>\n <li>Group size includes the pirate you’re reporting.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Common errors</h2>\n<ul>\n <li><b>“No such pirate registered.”</b> — Ask a mod to add them, or submit a <code>/report</code> first.</li>\n <li><b>“Character name is ambiguous.”</b> — Use the account tag (<code>Name#12345</code>).</li>\n <li><b>“You can only report the same pirate once every 10 minutes.”</b> — Wait a bit and try again.</li>\n</ul>\n"
|
||||
},
|
||||
"PirateReportCog.edit_pirate": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/edit_pirate</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n\n<p>Edit an existing pirate entry. Use this when a player changes nickname or account tag.</p>\n\n<ul>\n <li><b>Current account</b> — the exact existing account in the list (<code>Name#12345</code>).</li>\n <li><b>New in-game nickname</b> — optional.</li>\n <li><b>New account</b> — optional, must be <code>Name#12345</code>.</li>\n</ul>\n\n<div class=\"usage\" style=\"margin-top:.75rem\">/edit_pirate</div>\n\n<p style=\"margin-top:.6rem\"><small>\n You must provide <b>at least one</b> of: new nickname or new account.<br>\n The account format is strict: <code>#</code> + <b>five digits</b>. Duplicate accounts are blocked.\n</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>\n Updates a pirate record that’s already approved. Typical cases: the player renamed their character,\n or moved to a new account tag.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Form fields</h2>\n<ul>\n <li><b>Current account</b> — must match exactly what’s in the list (e.g., <code>SomeUser#12345</code>).</li>\n <li><b>New in-game nickname</b> — optional; leave empty to keep the current one.</li>\n <li><b>New account</b> — optional; must be <code>Name#12345</code>. If the new value already exists on another pirate, it’s rejected.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Rules & feedback</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>You must change at least one thing (nickname or account).</li>\n <li>On success you’ll see “✅ Pirate updated.” (ephemeral) and the public list refreshes.</li>\n <li>A short mod-log entry is posted for audit (“✏️ Edited pirate …”).</li>\n</ul>\n"
|
||||
},
|
||||
"PirateReportCog.encounters_migrate_ids": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/encounters_migrate_ids</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n\n<p>\n Converts old encounter records stored by <b>character name</b> to the\n canonical <b>account</b> (<code>Name#12345</code>).\n</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/encounters_migrate_ids</div>\n\n<p style=\"margin-top:.6rem\"><small>\n Useful after adding pirates where past encounters referenced only nicknames.\n</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>\n Scans stored encounters and rewrites any identifier that is a <b>character name</b> to the correct\n <b>account</b> for that pirate. Keeps data consistent for threat calculations.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">How matching works</h2>\n<ul>\n <li>If the encounter already uses an account (<code>#</code> + five digits), it’s counted as “already accounts”.</li>\n <li>If the character name maps to exactly one pirate, it’s updated to that pirate’s account.</li>\n <li>If there’s no pirate with that name, it’s counted as “not found”.</li>\n <li>If multiple pirates share the same nickname, it’s “ambiguous” and skipped.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Result</h2>\n<ul>\n <li>Replies with a compact summary (updated / already / ambiguous / not found).</li>\n <li>Refreshes the public pirate list afterwards.</li>\n</ul>\n\n<p style=\"margin-top:.6rem\"><small>\n <b>Moderator-only.</b>\n</small></p>\n"
|
||||
},
|
||||
"PirateReportCog.remove_pirate": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/remove_pirate</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n\n<p>Remove a pirate from the approved list by <b>account</b>.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/remove_pirate account_name: <code>Name#12345</code></div>\n\n<p style=\"margin-top:.6rem\"><small>\n Account format must be <code>Name#12345</code>. If the account isn’t found, you’ll get “Pirate not found.”\n</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>Deletes the matching pirate record from the approved list.</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Parameters</h2>\n<ul>\n <li><b>account_name</b> — exact account tag (<code>Name#12345</code>).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Behavior & messages</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>If no match: “Pirate not found.”</li>\n <li>On success: “Removed.” (ephemeral via slash).</li>\n <li>Mod-log note is posted (“🗑️ Removed pirate …”) and the public list refreshes.</li>\n</ul>\n"
|
||||
},
|
||||
"AutoVCCog.avc_cleanup_now": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/avc_cleanup_now</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Immediately deletes empty Auto-VC rooms that have been idle past the configured delay, then renumbers the rest.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/avc_cleanup_now</div>\n\n<p style=\"margin-top:.6rem\"><small>\n Replies “Cleanup pass complete.” on success.\n</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Scans the target category for <b>managed</b> Auto-VC rooms.</li>\n <li>Deletes rooms that have been <b>empty</b> for at least the configured <b>delay</b> (seconds).</li>\n <li>Renumbers remaining rooms to a clean sequence (<code>Prefix 1..N</code>).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>After a busy period to tidy up stale empties.</li>\n <li>If numbering looks off and you want a quick clean + renumber in one go.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Bot needs: <b>Manage Channels</b>, <b>Connect</b>, and <b>Move Members</b> in the target category to function smoothly.</li>\n <li>Safe to run anytime. The bot pauses the background sweeper while this runs to avoid conflicts.</li>\n</ul>\n"
|
||||
},
|
||||
"AutoVCCog.avc_renumber": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/avc_renumber</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Force a rename of all managed rooms to a clean numeric order without deleting anything.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/avc_renumber</div>\n\n<p style=\"margin-top:.6rem\"><small>\n Replies “Renumbered.” on success.\n</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>Renames tracked rooms to <code>{prefix} 1</code>, <code>{prefix} 2</code>, … in creation order. No rooms are deleted.</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>Numbering drifted after manual edits or channel deletions.</li>\n <li>You changed the name prefix and want all rooms aligned quickly.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Bot needs <b>Manage Channels</b> to rename rooms.</li>\n <li>Safe to run anytime; the bot sequences this against its background sweeper.</li>\n</ul>\n"
|
||||
},
|
||||
"AutoVCCog.avc_status": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/avc_status</code></h2>\n<p>Shows current Auto-VC setup and the list of managed rooms.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/avc_status</div>\n\n<p style=\"margin-top:.6rem\"><small>\n Output includes trigger channel, target category, name prefix, cleanup delay, and each tracked room with its current state.\n</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it shows</h2>\n<ul>\n <li><b>Trigger</b> — the voice channel that spawns new rooms.</li>\n <li><b>Category</b> — where new rooms are created.</li>\n <li><b>Prefix</b> — base name (e.g. <code>Room</code> → <code>Room 1</code>, <code>Room 2</code>…).</li>\n <li><b>Delay</b> — seconds a room must stay empty before cleanup can delete it.</li>\n <li><b>Tracked rooms</b> — each room’s name and whether it’s empty or how many are inside. If a room is empty, an “idle Ns” timer may appear.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Example output</h2>\n<pre style=\"white-space:pre-wrap;margin:.25rem 0 0\">\nAuto-VC status:\nTrigger: <#1234567890> | Category: <#2345678901> | Prefix: `Room` | Delay: 30s\n- #1: Room 1 — 3 inside\n- #2: Room 2 — empty | idle 12s\n</pre>\n\n<p style=\"margin-top:.75rem\"><small>\n This command is read-only and safe to run anytime.\n</small></p>\n"
|
||||
},
|
||||
"NickNudgeCog.clear_nick_reviews": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/clear_nick_reviews</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Delete all <b>pending</b> nickname review records for this server.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/clear_nick_reviews</div>\n\n<p style=\"margin-top:.6rem\"><small>\nReplies with how many pending entries were removed (ephemeral).\n</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Removes every <b>pending</b> entry in the nickname review queue for this server.</li>\n <li>Does <u>not</u> touch approved/rejected history or verification flags.</li>\n <li>Useful when the queue got noisy or stuck after a busy period.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Result</h2>\n<p>\n Shows: <code>Cleared N pending nickname review(s).</code> (ephemeral)\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Writes a short entry to the mod log (if configured).</li>\n</ul>\n"
|
||||
},
|
||||
"NickNudgeCog.recreate_nick_review": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/recreate_nick_review</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Recreate a <b>single</b> user’s missing pending review.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/recreate_nick_review <b>user:</b> @Member</div>\n\n<p style=\"margin-top:.6rem\"><small>\nIf they’re already verified or already have a pending review, you’ll get a short explanation instead (ephemeral).\n</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Checks the selected member:</li>\n <ul>\n <li>If <b>already verified</b> → no action.</li>\n <li>If a <b>pending review exists</b> → no action.</li>\n <li>Else → marks them as claimed (if needed) and opens a new pending review in the mod channel.</li>\n </ul>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>One user fell through the cracks and needs a review opened again.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Logs to the mod log (if configured) with who triggered it.</li>\n <li>Reply is ephemeral.</li>\n</ul>\n"
|
||||
},
|
||||
"NickNudgeCog.recreate_nick_reviews": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/recreate_nick_reviews</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Scan the server and recreate any <b>missing</b> pending reviews for users who claimed but never got a review opened.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/recreate_nick_reviews</div>\n\n<p style=\"margin-top:.6rem\"><small>\nReplies: <code>Recreated X review(s); skipped Y.</code> (ephemeral)\n</small></p>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Looks at users who <b>claimed</b> nickname compliance but are <u>not verified</u> and have <u>no pending review</u>.</li>\n <li>Opens a fresh review in the mod channel for each such user (one per user, no duplicates).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>After a restart/outage where some pending reviews may have gone missing.</li>\n <li>Whenever you see “claimed” users without an open review.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Posts a summary to the mod log (if configured).</li>\n <li>Only processes this server; other servers are unaffected.</li>\n</ul>\n"
|
||||
},
|
||||
"PiratesListCog.pirates_list_refresh": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/pirates_list_refresh</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Rebuild the compact pirates list in the configured channel. Use this after adding/removing pirates or when encounter stats change.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/pirates_list_refresh</div>\n\n<ul style=\"margin:.6rem 0 0 1rem\">\n <li>Replies: <code>Pirates list refreshed.</code> (ephemeral)</li>\n <li>Runs per-guild cooldown: 10s</li>\n</ul>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Builds a compact, alphabetized list of verified pirates and posts it in the configured list channel.</li>\n <li>Entries are chunked to stay under Discord’s 2000-character limit; old chunks are edited or deleted as needed.</li>\n <li>No one gets pinged — all content is sanitized to avoid accidental mentions.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Entry format</h2>\n<pre style=\"margin:.5rem 0;white-space:pre-wrap\">\n- <b>Character</b> (<i>Account#12345</i>) [Threat%]\n - In group: <i>bucket</i>. Destructive: <i>bucket</i>. Encounters: N. Last: <t:UNIX:R>\n</pre>\n<ul>\n <li><b>Threat%</b> reflects combined encounter signals (kills, destructive behavior, group size, skill).</li>\n <li><b>In group / Destructive</b> use buckets: <code>unknown</code>, <code>never</code>, <code>rarely</code>, <code>sometimes</code>, <code>often</code>, <code>always</code>.</li>\n <li><b>Last</b> shows the most recent encounter as a relative timestamp.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>After a new pirate is approved or removed.</li>\n <li>After logging encounters that meaningfully change threat or buckets.</li>\n <li>When the list looks stale or out of order.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Behavior & notes</h2>\n<ul>\n <li>Sorts by character name, then account.</li>\n <li>If there are no pirates yet, shows a single placeholder line.</li>\n <li>If the list channel isn’t configured or can’t be found, nothing is posted (check your config).</li>\n <li>Per-guild cooldown: 10 seconds (prevents spam).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Config that affects output</h2>\n<ul>\n <li><code>pirates_list_channel_id</code> — where the list is posted.</li>\n <li><code>threat_group_threshold</code> — minimum party size that counts as “in group”.</li>\n <li><code>threat_min_samples_for_stats</code> — encounters needed before buckets stop showing <code>unknown</code>.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Bot needs permission to read and write in the list channel.</li>\n</ul>\n"
|
||||
},
|
||||
"PowerActionsCog.power_restart": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/power restart</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Safely restarts the bot after logging a clear reason to the modlog.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/power restart reason:\"Restarting to enable new permissions sync after config change\"</div>\n\n<ul style=\"margin:.6rem 0 0 1rem\">\n <li>Requires a descriptive reason (see details).</li>\n <li>Replies: <code>🔁 Restart requested — logging to modlog and restarting…</code> (ephemeral)</li>\n <li>Bot restarts a couple seconds after the reply.</li>\n</ul>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Checks you’re a moderator, validates your reason, and posts a restart entry to the modlog (who, when, version, reason).</li>\n <li>Closes the bot cleanly and exits; your process manager/container brings it back up.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Reason requirements</h2>\n<p>The <code>reason</code> must be specific enough to audit later:</p>\n<ul>\n <li>At least <b>20 characters</b></li>\n <li>At least <b>4 words</b></li>\n <li>At least <b>3 words</b> with length ≥ 3</li>\n <li>Rejects vague phrases like: <code>stuck</code>, <code>idk</code>, <code>don't know</code>, <code>unknown</code>, <code>?</code>,\n <code>lag</code>, <code>restart</code>, <code>restarting</code>, <code>update</code>, <code>updating</code>,\n <code>bug</code>, <code>crash</code>, <code>crashed</code></li>\n</ul>\n\n<h3 style=\"margin:.75rem 0 .4rem\">Examples</h3>\n<div style=\"display:grid; gap:.5rem\">\n <div>\n <div style=\"font-weight:600\">✅ Good</div>\n <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"Reloading cogs after changing threat weights and enabling nick loop; avoids inconsistent state.\"</pre>\n </div>\n <div>\n <div style=\"font-weight:600\">❌ Bad</div>\n <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"update\"</pre>\n </div>\n</div>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">What you’ll see</h2>\n<ul>\n <li>Ephemeral confirmation in chat.</li>\n <li>Modlog post similar to:\n <pre style=\"margin:.4rem 0;white-space:pre-wrap\">🔁 Bot Restart Requested\nBy: @YourName\nWhen: 2025-01-01 12:34 UTC\nRunning version: vX.Y.Z\nReason: Reloading cogs after threat weight change…</pre>\n </li>\n <li>Bot goes offline briefly, then comes back once the host restarts it.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Config that affects it</h2>\n<ul>\n <li><code>modlog_channel_id</code> — where the restart entry is posted. If not set, it falls back to server logs only.</li>\n <li><code>home_guild_id</code> — where the <code>/power</code> group is registered (home-guild only vs global).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions</h2>\n<ul>\n <li><b>Moderator-only.</b> Non-mods are blocked with an ephemeral message.</li>\n <li>Bot just needs permission to send messages in the modlog channel.</li>\n</ul>\n"
|
||||
},
|
||||
"PowerActionsCog.restart": {
|
||||
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/power restart</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Safely restarts the bot after logging a clear reason to the modlog.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/power restart reason:\"Restarting to enable new permissions sync after config change\"</div>\n\n<ul style=\"margin:.6rem 0 0 1rem\">\n <li>Requires a descriptive reason (see details).</li>\n <li>Replies: <code>🔁 Restart requested — logging to modlog and restarting…</code> (ephemeral)</li>\n <li>Bot restarts a couple seconds after the reply.</li>\n</ul>\n",
|
||||
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Checks you’re a moderator, validates your reason, and posts a restart entry to the modlog (who, when, version, reason).</li>\n <li>Closes the bot cleanly and exits; your process manager/container brings it back up.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Reason requirements</h2>\n<p>The <code>reason</code> must be specific enough to audit later:</p>\n<ul>\n <li>At least <b>20 characters</b></li>\n <li>At least <b>4 words</b></li>\n <li>At least <b>3 words</b> with length ≥ 3</li>\n <li>Rejects vague phrases like: <code>stuck</code>, <code>idk</code>, <code>don't know</code>, <code>unknown</code>, <code>?</code>,\n <code>lag</code>, <code>restart</code>, <code>restarting</code>, <code>update</code>, <code>updating</code>,\n <code>bug</code>, <code>crash</code>, <code>crashed</code></li>\n</ul>\n\n<h3 style=\"margin:.75rem 0 .4rem\">Examples</h3>\n<div style=\"display:grid; gap:.5rem\">\n <div>\n <div style=\"font-weight:600\">✅ Good</div>\n <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"Reloading cogs after changing threat weights and enabling nick loop; avoids inconsistent state.\"</pre>\n </div>\n <div>\n <div style=\"font-weight:600\">❌ Bad</div>\n <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"update\"</pre>\n </div>\n</div>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">What you’ll see</h2>\n<ul>\n <li>Ephemeral confirmation in chat.</li>\n <li>Modlog post similar to:\n <pre style=\"margin:.4rem 0;white-space:pre-wrap\">🔁 Bot Restart Requested\nBy: @YourName\nWhen: 2025-01-01 12:34 UTC\nRunning version: vX.Y.Z\nReason: Reloading cogs after threat weight change…</pre>\n </li>\n <li>Bot goes offline briefly, then comes back once the host restarts it.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Config that affects it</h2>\n<ul>\n <li><code>modlog_channel_id</code> — where the restart entry is posted. If not set, it falls back to server logs only.</li>\n <li><code>home_guild_id</code> — where the <code>/power</code> group is registered (home-guild only vs global).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions</h2>\n<ul>\n <li><b>Moderator-only.</b> Non-mods are blocked with an ephemeral message.</li>\n <li>Bot just needs permission to send messages in the modlog channel.</li>\n</ul>\n"
|
||||
},
|
||||
"ReactionRoleCog.nick_same": {
|
||||
"brief_html": "<h2>/nick_same</h2>\n<p><strong>Nick matches my in-game name.</strong> Tells mods your current <em>server nickname</em> (or global display name if you haven’t set one) already matches your in-game name, and opens a quick review.</p>\n\n<h3>Usage</h3>\n<pre>/nick_same</pre>\n\n<ul>\n <li>You’ll get an <em>ephemeral</em> “thanks” message.</li>\n <li>Moderators get a review with ✅ / ❌ in the mod channel.</li>\n <li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + this nickname claim (pending or verified).</li>\n</ul>\n\n<h3>Tips</h3>\n<ul>\n <li>Run it in the server (not in DMs).</li>\n <li>If you recently changed your nick, this will trigger a re-review.</li>\n <li>Alternative: react ✅ on the “Nickname” rules message to claim the same thing.</li>\n</ul>\n",
|
||||
"details_html": "<h1>/nick_same — Nickname claim</h1>\n<p>Use this when your <strong>server nickname</strong> already matches your in-game character name. It opens a moderator review so you can get (or keep) access without typing anything else.</p>\n\n<h2>What it does</h2>\n<ul>\n <li>Marks your nickname as <em>claimed</em> and opens exactly <strong>one</strong> pending review for moderators.</li>\n <li>You’ll see a short <em>ephemeral</em> confirmation in the channel.</li>\n <li>Moderators get a message in the mod channel with ✅ Approve / ❌ Reject.</li>\n <li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + Nickname <em>claimed</em> (pending or verified).</li>\n</ul>\n\n<h2>How to use</h2>\n<pre>/nick_same</pre>\n<ul>\n <li>Run it in the server, any channel where you can use slash commands.</li>\n <li>No extra arguments. If your display name already matches the game name, that’s enough.</li>\n</ul>\n\n<h2>Typical flow</h2>\n<ol>\n <li><strong>You</strong> run <code>/nick_same</code>. The bot replies “Thanks — your nickname claim was sent for moderator review.”</li>\n <li><strong>Mods</strong> see a review card with your previous nick (if known) and the current one.</li>\n <li>They press ✅ to verify or ❌ to reject. You keep (or lose) Full Access depending on Rules/Engagement and the result.</li>\n</ol>\n\n<h2>Good to know</h2>\n<ul>\n <li>If a verified user changes their nickname later, verification is <strong>automatically revoked</strong>. Just run <code>/nick_same</code> again (or react ✅ on the nickname message) to re-claim and re-review.</li>\n <li>The command is idempotent: if a review is already pending, it won’t open duplicates.</li>\n <li>Alternative path: reacting ✅ on the <em>Nickname</em> rules message triggers the same review and access checks.</li>\n</ul>\n\n<h2>Troubleshooting</h2>\n<ul>\n <li><strong>No response?</strong> Make sure you used it inside the server (not DMs) and that slash commands are allowed in the channel.</li>\n <li><strong>Still no Full Access?</strong> You need all three: Rules ✅, Engagement ✅, and a nickname claim (pending or verified). Missing any one will remove the role.</li>\n <li><strong>Mods didn’t get a review?</strong> Ping a moderator; the mod channel might be misconfigured.</li>\n</ul>\n"
|
||||
},
|
||||
"SpicePayCog.spicepay": {
|
||||
"brief_html": "<h2>/spicepay</h2>\n<p><strong>Open the Spice Pay wizard.</strong> Step through participants, roles, payout type (Sand or Melange), and weighting. Shows a live preview and lets you post the result.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay [participants] [force_new]</pre>\n<ul>\n <li><code>participants</code> (optional): total people, incl. owners (1–25).</li>\n <li><code>force_new</code> (optional): start fresh if you already have a session.</li>\n</ul>\n\n<h3>Quick flow</h3>\n<ol>\n <li>Enter the total yield and participant count.</li>\n <li>Add/edit each participant (name, active %, owner roles).</li>\n <li>Toggle payout: Sand ⟷ Melange (set refinery yield for Melange).</li>\n <li>Adjust weighting factors or use a preset.</li>\n <li>Finish → preview → Post to channel.</li>\n</ol>\n\n<h3>Notes</h3>\n<ul>\n <li>0% active = owner-only (gets owner perks but didn’t actively join).</li>\n <li>Melange: exactly one <em>Refiner</em> owner is required.</li>\n <li>Rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>\n</ul>",
|
||||
"details_html": "<h1>/spicepay — Guided payout</h1>\n<p>Calculate a fair split for a spice run. The wizard keeps things simple and transparent for the team.</p>\n\n<h2>Start</h2>\n<pre>/spicepay [participants] [force_new]</pre>\n<ul>\n <li>If <code>participants</code> is omitted, you set it in the first modal (1–25).</li>\n <li>Use <code>force_new: true</code> to discard an existing session and start clean.</li>\n</ul>\n\n<h2>Setup modal</h2>\n<ul>\n <li><strong>Total spice yield (sand)</strong> — integer ≥ 0.</li>\n <li><strong>Participants</strong> — include <em>owners</em> (Refiner/Carrier/Crawler). People only; vehicles/refinery are <em>owned</em> by people.</li>\n</ul>\n\n<h2>Editing participants</h2>\n<p>For each slot:</p>\n<ul>\n <li><strong>Name</strong> — free text (mention text is fine).</li>\n <li><strong>Active %</strong> — 0–100. <em>0% = owner-only</em> (didn’t actively join).</li>\n <li><strong>Owner of</strong> (optional) — any of: <code>refiner</code>, <code>carrier</code>, <code>crawler</code>. Flexible input (e.g. “lsr”, “refinery”, “car”, “cr”).</li>\n</ul>\n<p>Use <em>Add / Edit participant</em>, <em>Previous/Next</em> to navigate. The preview shows filled vs. empty slots and highlights “owner-only”.</p>\n\n<h2>Payout type</h2>\n<ul>\n <li><strong>Sand</strong> — no refinery cut; 0 or 1 Refiner owner allowed.</li>\n <li><strong>Melange</strong> — <em>requires exactly one</em> Refiner owner. First a refinery cut % is taken and paid to the Refiner, then the rest is split.</li>\n</ul>\n<p>When switching to <strong>Melange</strong>, set the refinery yield (integer) in the modal.</p>\n\n<h2>Weighting (the math, simplified)</h2>\n<ul>\n <li>Each person gets a <em>weight</em>: <strong>Base × Active%</strong> + bonuses for owning <strong>Carrier</strong>/<strong>Crawler</strong>.</li>\n <li>We split the pot <em>proportionally</em> by weights (they’re normalized; only ratios matter).</li>\n <li>Melange only: take <strong>Refinery cut %</strong> first → Refiner. Any rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>\n</ul>\n\n<h3>Controls</h3>\n<ul>\n <li><em>Toggle payout: Sand/Melange</em> — switches mode (prompts for refinery yield on Melange).</li>\n <li><em>Adjust weighting factors</em> — change:\n <ul>\n <li><strong>Base × active %</strong> (default <em>server setting</em>)</li>\n <li><strong>Carrier bonus</strong> ( + )</li>\n <li><strong>Crawler bonus</strong> ( + )</li>\n <li><strong>Refinery cut %</strong> (Melange only)</li>\n </ul>\n </li>\n <li><em>Weights help</em> — short explanation inline.</li>\n <li><em>Presets</em> — Owner-heavy / Participation-heavy / Fair (server defaults) / Even split.</li>\n <li><em>Save as my defaults</em> — stores your weights for future runs (per user).</li>\n <li><em>Finish</em> — shows a neat preview (table) and lets you <em>Post to channel</em>.</li>\n</ul>\n\n<h2>Validation & limits</h2>\n<ul>\n <li>Participants: 1–25.</li>\n <li>Active %: 0–100.</li>\n <li>Melange: exactly one Refiner owner required; Sand: 0 or 1 allowed.</li>\n <li>No duplicate names (duplicates are auto-disambiguated in preview).</li>\n</ul>\n\n<h2>Posting</h2>\n<p>The post includes:</p>\n<ul>\n <li>Header (Sand or Melange) with the chosen emoji.</li>\n <li>Weighting summary (Base, bonuses, and refinery cut if Melange).</li>\n <li>A clean monospace table: Name · Active% · Owner of/Role · Amount.</li>\n</ul>\n<p><em>Footnote:</em> “0% = owner only”.</p>\n"
|
||||
},
|
||||
"SpicePayCog.spicepay_cancel": {
|
||||
"brief_html": "<h2>/spicepay_cancel</h2>\n<p><strong>Cancel your active Spice Pay session.</strong> Clears everything for you so you can start fresh.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay_cancel</pre>\n\n<ul>\n <li>Removes your in-memory session (does not post anything).</li>\n <li>Run <code>/spicepay</code> again to start over.</li>\n</ul>\n",
|
||||
"details_html": "<h1>/spicepay_cancel — Cancel session</h1>\n<p>Discards your current wizard state. Handy if you mis-entered totals or want to restructure the roster.</p>\n\n<h2>What it does</h2>\n<ul>\n <li>Deletes your session (you + this server).</li>\n <li>Nothing is posted publicly.</li>\n</ul>\n\n<h2>Next steps</h2>\n<p>Run <code>/spicepay</code> to start again. You can optionally pass <code>participants</code> or just set them in the first modal.</p>\n"
|
||||
},
|
||||
"SpicePayCog.spicepay_config": {
|
||||
"brief_html": "<h2>/spicepay_config</h2>\n<p><strong>Show server SpicePay weights.</strong> Read-only view of the defaults the wizard uses.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay_config</pre>\n\n<ul>\n <li>Displays: Refinery cut %, Base × active %, Carrier bonus, Crawler bonus.</li>\n <li>Change via ENV/INI; restart the bot to apply.</li>\n</ul>\n",
|
||||
"details_html": "<h1>/spicepay_config — Server weights</h1>\n<p>Shows the current default weighting used by the Spice Pay wizard.</p>\n\n<h2>Fields</h2>\n<ul>\n <li><strong>Refinery cut %</strong> (Melange only) — percent taken first and paid to the Refiner owner.</li>\n <li><strong>Base × active %</strong> — the core share, scaled by each player’s active participation.</li>\n <li><strong>Carrier bonus</strong> — flat weight added if the person owns the carrier.</li>\n <li><strong>Crawler bonus</strong> — flat weight added if the person owns the crawler.</li>\n</ul>\n\n<h2>Where to change</h2>\n<p>Configure via environment variables or your INI, then restart the bot. The wizard’s <em>Adjust weighting factors</em> lets users override per-run; those don’t change the server defaults.</p>\n\n<h2>Tips</h2>\n<ul>\n <li>Use <em>Presets</em> in the wizard to quickly switch philosophy (owner-heavy, participation-heavy, etc.).</li>\n <li>Users can <em>Save as my defaults</em> to keep their personal weights for future runs.</li>\n</ul>\n"
|
||||
},
|
||||
"SpicePayCog.spicepay_resume": {
|
||||
"brief_html": "<h2>/spicepay_resume</h2>\n<p><strong>Reopen your active Spice Pay session.</strong> Useful if you closed the wizard message by accident.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay_resume</pre>\n\n<ul>\n <li>If a session exists, the wizard view is shown again.</li>\n <li>If not, you’ll be told to run <code>/spicepay</code>.</li>\n</ul>\n",
|
||||
"details_html": "<h1>/spicepay_resume — Resume wizard</h1>\n<p>Brings back the current session UI (if you already started one with <code>/spicepay</code>).</p>\n\n<h2>When to use</h2>\n<ul>\n <li>You dismissed the ephemeral wizard message and want it back.</li>\n <li>You’re mid-entry and don’t want to start over.</li>\n</ul>\n\n<h2>Behavior</h2>\n<ul>\n <li>If a session exists (keyed to you + this server), it re-renders the progress view.</li>\n <li>If none exists, you get a friendly nudge to start a new one.</li>\n</ul>\n"
|
||||
},
|
||||
"help": {
|
||||
"brief_html": "<h2>!help</h2>\n<p>Show a quick overview of commands or details about a specific command.</p>\n\n<h3>Usage</h3>\n<pre>!help\n!help spicepay\n!help pirate\n!help encounter</pre>\n\n<ul>\n <li>Add a word to narrow down the list (e.g., <code>!help spice</code>).</li>\n</ul>\n",
|
||||
"details_html": "<h1>!help — Command help & search</h1>\n<p>Use <code>!help</code> to see what the bot can do, or pass a keyword/command to jump straight to specifics.</p>\n\n<h2>What it does</h2>\n<ul>\n <li>Lists available commands you can run here.</li>\n <li>Filters by a keyword (partial matches work).</li>\n <li>When supported, shows usage and a short explanation for a specific command.</li>\n</ul>\n\n<h2>How to use it</h2>\n<ol>\n <li><strong>Overview:</strong> <code>!help</code> — shows a concise command list.</li>\n <li><strong>Filter:</strong> <code>!help spice</code> — narrows to commands that match “spice”.</li>\n <li><strong>Exact command:</strong> <code>!help encounter</code> — shows usage for a single command if available.</li>\n</ol>\n\n<h2>Examples</h2>\n<pre>!help\n!help pirate\n!help spicepay\n!help encounter</pre>\n\n<h2>Notes & tips</h2>\n<ul>\n <li>If you don’t get any results, try a simpler keyword (e.g., <em>“spice”</em> instead of <em>“spicepay resume”</em>).</li>\n</ul>\n\n<h2>Troubleshooting</h2>\n<ul>\n <li><em>“No such command”</em> — Check your spelling or try a broader keyword.</li>\n</ul>\n"
|
||||
},
|
||||
"PirateCardsCog.pirate_cards_rebuild": {
|
||||
"brief_html": "<h2>/pirate_cards_rebuild</h2>\n<p><strong>Rebuild or update pirate cards</strong> for every known pirate in the configured channel.</p>\n\n<h3>Usage</h3>\n<pre>/pirate_cards_rebuild</pre>\n\n<ul>\n <li><strong>Moderator-only</strong>.</li>\n <li>Run in a server channel. The reply is ephemeral.</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n <li>Creates or updates one embed “card” per pirate in the pirates list channel.</li>\n <li>Each card shows Account, Threat %, grouping/destructive behavior buckets, encounters count, and last seen date.</li>\n <li>Card color reflects threat (unknown → gray; low → green; high → red).</li>\n</ul>\n",
|
||||
"details_html": "<h1>/pirate_cards_rebuild — Rebuild all pirate cards</h1>\n<p>Runs a full pass that makes sure each verified pirate has a fresh, accurate card in the configured channel.</p>\n\n<h2>Access</h2>\n<ul>\n <li><strong>Moderator-only</strong>.</li>\n <li>Must be used in a server channel (not DMs). The confirmation is sent <em>ephemerally</em>.</li>\n</ul>\n\n<h2>What it updates</h2>\n<ul>\n <li><strong>Card fields</strong>: Account name, Threat %, “In groups” bucket, “Destructive” bucket, total encounters, and last encounter date (UTC).</li>\n <li><strong>Colors</strong>: Unknown samples → dark gray. Otherwise a green→red gradient based on Threat %.</li>\n <li><strong>Safety</strong>: User-provided names are escaped and mentions are neutralized (no pings).</li>\n</ul>\n\n<h2>Behavior</h2>\n<ol>\n <li>For each pirate in the internal list:\n <ul>\n <li>If their card already exists, it edits the embed in place.</li>\n <li>If it can’t find the message, it posts a new card and records the message ID.</li>\n </ul>\n </li>\n <li>Works serially per guild to avoid race conditions.</li>\n</ol>\n\n<h2>Output</h2>\n<p>Ephemeral summary, e.g.:</p>\n<pre>Rebuilt/updated 27 pirate cards.</pre>\n\n<h2>Setup notes</h2>\n<ul>\n <li>Uses <code>pirates_list_channel_id</code> for the destination channel.</li>\n <li>Threat %, group/destructive buckets, and last seen are computed from stored encounters.</li>\n <li>Bucket thresholds respect your server settings (e.g., <code>threat_group_threshold</code>, <code>threat_min_samples_for_stats</code>).</li>\n</ul>\n\n<h2>When to run it</h2>\n<ul>\n <li>After importing or migrating data.</li>\n <li>After a batch of new pirates is approved.</li>\n <li>Any time the cards look out of date.</li>\n</ul>\n"
|
||||
},
|
||||
"UserCardsCog.usercards_rescan": {
|
||||
"brief_html": "<h2>/usercards_rescan</h2>\n<p><strong>Re-check everyone and refresh the user cards.</strong> Also repairs Roles/RoE/nickname claims from the live reaction messages, and re-opens any missing nickname reviews.</p>\n\n<h3>Usage</h3>\n<pre>/usercards_rescan</pre>\n\n<ul>\n <li><strong>Moderator-only</strong> (requires <em>Manage Server</em>).</li>\n <li>Runs in the server; reply is ephemeral with a short summary.</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n <li>Reconciles from the configured Rules / RoE / Nickname reaction messages.</li>\n <li>Grants/removes the Rules & RoE roles to match reactions.</li>\n <li>For Nickname: opens a pending review if someone claimed but no review exists.</li>\n <li>Rebuilds/updates every user’s status card in the list channel.</li>\n</ul>\n",
|
||||
"details_html": "<h1>/usercards_rescan — Reconcile & refresh all cards</h1>\n<p>One-shot maintenance pass that makes the server’s user cards match reality.</p>\n\n<h2>Access</h2>\n<ul>\n <li><strong>Moderator-only</strong> — requires the Discord permission <em>Manage Server</em>.</li>\n <li>Must be used in a server channel (not DMs). The result is sent <em>ephemerally</em>.</li>\n</ul>\n\n<h2>What it fixes</h2>\n<ol>\n <li><strong>Rules / RoE agreement</strong>\n <ul>\n <liReads reactions from your configured Rules and RoE messages.</li>\n <li>Adds/removes the corresponding roles so roles match the reactions.</li>\n </ul>\n </li>\n <li><strong>Nickname claim & reviews</strong>\n <ul>\n <li>If a member has an “accept” reaction on the Nickname message but has <em>no</em> pending/verified record and <em>no</em> open review, it opens a <em>pending nickname review</em> for them.</li>\n <li>If a member <em>removed</em> their Nickname reaction, it clears any pending/verified state.</li>\n </ul>\n </li>\n <li><strong>User cards</strong>\n <ul>\n <li>Updates (or recreates) the embed for each member in the configured “users list” channel.</li>\n <li>Card color reflects: Rules, RoE, and Nickname status (✅ verified / ✔️ pending / ❌ not done).</li>\n <li>Uses a stable footer marker (<code>UID:<id></code>) to find/edit the right card; cleans up duplicates.</li>\n </ul>\n </li>\n</ol>\n\n<h2>Expected output</h2>\n<p>The command replies (ephemeral) with counts like:</p>\n<pre>Reconciled from messages. Changes — Rules: 3, RoE: 2, Nickname (added): 1, Nickname (removed): 0. Refreshed cards for 154 members.</pre>\n\n<h2>Setup notes</h2>\n<ul>\n <li>Relies on your configured IDs (ENV/INI): Rules/RoE/Nickname message IDs and their role IDs, the Full Access role, the user-cards channel, and the mod/modlog channels.</li>\n <li>Won’t ping anyone; all posts/edits are sent with <em>no mentions</em>.</li>\n</ul>\n\n<h2>Tips</h2>\n<ul>\n <li>Run after importing a server, restoring from backup, or after downtime.</li>\n <li>Large servers: this may take a moment while it walks members and edits cards. It’s safe to run again.</li>\n</ul>\n"
|
||||
},
|
||||
"DDLootTableCog.dd_update": {
|
||||
"brief_html": "<h2>/dd_update</h2>\n<p><strong>Control the Deep Desert weekly loot updater.</strong> Stop/resume the scheduler or force a one-off start.</p>\n\n<h3>Usage</h3>\n<pre>/dd_update <action> [reason]</pre>\n<ul>\n <li><code>action</code> — one of <code>stop</code>, <code>resume</code>, <code>start</code>.</li>\n <li><code>reason</code> (optional) — short note shown in the confirmation.</li>\n</ul>\n\n<h3>Permissions</h3>\n<ul>\n <li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>\n <li><strong>start</strong>: <em>Server Owner only</em> (dangerous; bypasses the usual wait).</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n <li><em>stop</em> — pauses all checks until <code>resume</code>.</li>\n <li><em>resume</em> — returns to the normal weekly cycle.</li>\n <li><em>start</em> — behaves as if the weekly reset just happened and begins polling immediately.</li>\n</ul>\n",
|
||||
"details_html": "<h1>/dd_update — Deep Desert updater controls</h1>\n<p>Manage the weekly “Deep Desert — Weekly Uniques” message updater for this guild.</p>\n\n<h2>Usage</h2>\n<pre>/dd_update <action> [reason]</pre>\n<ul>\n <li><code>action</code> — <code>stop</code> | <code>resume</code> | <code>start</code></li>\n <li><code>reason</code> (optional) — free text appended to the confirmation response.</li>\n</ul>\n\n<h2>Permissions</h2>\n<ul>\n <li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>\n <li><strong>start</strong>: <em>Server Owner only</em>. Use with care—this forces an immediate check cycle.</li>\n</ul>\n\n<h2>Behavior</h2>\n<ul>\n <li><strong>Weekly reset:</strong> Tuesdays <strong>03:00 UTC</strong>. At reset, the bot updates the channel message to indicate it’s waiting for the new week.</li>\n <li><strong>Polling cadence:</strong> adaptive back-off until fresh data is found:\n <ul>\n <li>Every 5 min for the first hour</li>\n <li>Then every 15 min until 3 hours</li>\n <li>Then every 30 min until 6 hours</li>\n <li>Then every 1 hour until 24 hours</li>\n <li>Then every 3 hours</li>\n </ul>\n </li>\n <li><strong>Success:</strong> when new valid data appears, the bot updates the message once and idles until the next weekly reset.</li>\n <li><strong>Errors / no update yet:</strong> the message shows a generic notice that it’s still waiting or that an issue occurred (no external source is mentioned).</li>\n</ul>\n\n<h2>Actions</h2>\n<ul>\n <li><strong>stop</strong> — Pauses all checks. The message remains as-is until you <code>resume</code>.</li>\n <li><strong>resume</strong> — Returns to the normal weekly cycle (detect reset, then poll until new data).</li>\n <li><strong>start</strong> — Pretends the weekly reset just happened and begins polling now. <em>Owner-only.</em></li>\n</ul>\n\n<h2>Channel & config</h2>\n<ul>\n <li><strong>Target channel:</strong> taken from <code>SHAI_DD_CHANNEL_ID</code> (env). If unset, defaults to <code>1404764793377652807</code>.</li>\n <li><strong>Scope:</strong> guild-specific. The message lives in this server’s configured channel.</li>\n</ul>\n\n<h2>Notes</h2>\n<ul>\n <li>This command only controls the updater; it does not manually edit the posted message.</li>\n <li>On each successful weekly update, the bot compares content against the previous week to avoid duplicate posts.</li>\n</ul>\n"
|
||||
}
|
||||
}
|
12
assets/docs/commands/help.brief.html
Normal 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>
|
32
assets/docs/commands/help.details.html
Normal file
@ -0,0 +1,32 @@
|
||||
<h1>!help — Command help & search</h1>
|
||||
<p>Use <code>!help</code> to see what the bot can do, or pass a keyword/command to jump straight to specifics.</p>
|
||||
|
||||
<h2>What it does</h2>
|
||||
<ul>
|
||||
<li>Lists available commands you can run here.</li>
|
||||
<li>Filters by a keyword (partial matches work).</li>
|
||||
<li>When supported, shows usage and a short explanation for a specific command.</li>
|
||||
</ul>
|
||||
|
||||
<h2>How to use it</h2>
|
||||
<ol>
|
||||
<li><strong>Overview:</strong> <code>!help</code> — shows a concise command list.</li>
|
||||
<li><strong>Filter:</strong> <code>!help spice</code> — narrows to commands that match “spice”.</li>
|
||||
<li><strong>Exact command:</strong> <code>!help encounter</code> — shows usage for a single command if available.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Examples</h2>
|
||||
<pre>!help
|
||||
!help pirate
|
||||
!help spicepay
|
||||
!help encounter</pre>
|
||||
|
||||
<h2>Notes & tips</h2>
|
||||
<ul>
|
||||
<li>If you don’t get any results, try a simpler keyword (e.g., <em>“spice”</em> instead of <em>“spicepay resume”</em>).</li>
|
||||
</ul>
|
||||
|
||||
<h2>Troubleshooting</h2>
|
||||
<ul>
|
||||
<li><em>“No such command”</em> — Check your spelling or try a broader keyword.</li>
|
||||
</ul>
|
BIN
assets/docs/favicon-16x16.png
Normal file
After Width: | Height: | Size: 692 B |
BIN
assets/docs/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/docs/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
7
assets/docs/no.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 22 16">
|
||||
<title>Flag of Norway</title>
|
||||
<rect width="22" height="16" fill="#ba0c2f"/>
|
||||
<path d="M0,8h22M8,0v16" stroke="#fff" stroke-width="4"/>
|
||||
<path d="M0,8h22M8,0v16" stroke="#00205b" stroke-width="2"/>
|
||||
</svg>
|
After Width: | Height: | Size: 319 B |
12
assets/docs/site.webmanifest
Normal 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": "/"
|
||||
}
|
BIN
assets/images/escort_logo.png
Normal file
After Width: | Height: | Size: 49 KiB |
108
assets/images/escort_logo.svg
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
assets/images/feydakin_logo.png
Normal file
After Width: | Height: | Size: 53 KiB |
93
assets/images/feydakin_logo.svg
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
assets/images/harvester_logo.png
Normal file
After Width: | Height: | Size: 46 KiB |
51
assets/images/harvester_logo.svg
Normal file
After Width: | Height: | Size: 20 KiB |
261
bot.py
@ -1,53 +1,43 @@
|
||||
import os
|
||||
import asyncio
|
||||
import os, signal, asyncio, pathlib
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from dotenv import load_dotenv
|
||||
from configparser import ConfigParser
|
||||
|
||||
from data_manager import DataManager
|
||||
import pathlib
|
||||
import os, asyncio, xml.etree.ElementTree as ET
|
||||
import aiohttp
|
||||
from modules.common.settings import cfg as cfg_helper
|
||||
from modules.common.boot_notice import post_boot_notice
|
||||
|
||||
VERSION="0.0.9"
|
||||
|
||||
# ---------- Env & config loading ----------
|
||||
# Version consists of:
|
||||
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
||||
VERSION = "0.5.1.2.a4"
|
||||
|
||||
# ---------- Env loading ----------
|
||||
load_dotenv()
|
||||
|
||||
TOKEN = os.getenv('DISCORD_TOKEN', '').strip()
|
||||
CONFIG_PATH = os.getenv('SHAI_CONFIG', '/config/settings.conf')
|
||||
def _get_env(name: str, default: str = "") -> str:
|
||||
v = os.getenv(name, "")
|
||||
return (v or "").strip().strip('"').strip("'") or default
|
||||
|
||||
config = ConfigParser()
|
||||
read_files = config.read(CONFIG_PATH)
|
||||
if not read_files:
|
||||
print(f"[Config] INFO: no config at {CONFIG_PATH} (or unreadable). Will rely on env + defaults.")
|
||||
TOKEN = _get_env("DISCORD_TOKEN")
|
||||
DATA_FILE = _get_env("DATA_FILE") or "./data/data.json"
|
||||
|
||||
# Ensure DEFAULT section exists
|
||||
if 'DEFAULT' not in config:
|
||||
config['DEFAULT'] = {}
|
||||
print("[Config] DISCORD_TOKEN set:", bool(TOKEN))
|
||||
print("[Config] DATA_FILE:", DATA_FILE)
|
||||
|
||||
def _overlay_env_into_config(cfg: ConfigParser):
|
||||
"""
|
||||
Overlay all SHAI_* environment variables into cfg['DEFAULT'] so env wins.
|
||||
Also accept SHAI_DATA_FILE or SHAI_DATA for data_file.
|
||||
"""
|
||||
d = cfg['DEFAULT']
|
||||
# ---------- Ensure data path exists (fallback if not writable) ----------
|
||||
data_dir = os.path.dirname(DATA_FILE) or "."
|
||||
try:
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
except PermissionError:
|
||||
fallback = "./data/data.json"
|
||||
print(f"[Config] No permission to create '{data_dir}'. Falling back to {fallback}")
|
||||
DATA_FILE = fallback
|
||||
data_dir = os.path.dirname(DATA_FILE)
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
# Map SHAI_* -> lower-case keys (e.g. SHAI_MOD_CHANNEL_ID -> 'mod_channel_id')
|
||||
for k, v in os.environ.items():
|
||||
if not k.startswith('SHAI_'):
|
||||
continue
|
||||
key = k[5:].lower() # drop 'SHAI_' prefix
|
||||
if key == 'data':
|
||||
key = 'data_file'
|
||||
d[key] = str(v)
|
||||
|
||||
if not d.get('data_file', '').strip():
|
||||
d['data_file'] = '/data/data.json'
|
||||
|
||||
# Apply overlay so env takes precedence everywhere
|
||||
_overlay_env_into_config(config)
|
||||
if not os.path.exists(DATA_FILE):
|
||||
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
|
||||
# ---------- Discord intents ----------
|
||||
|
||||
@ -61,33 +51,56 @@ intents.voice_states = True
|
||||
|
||||
# ---------- Bot + DataManager ----------
|
||||
|
||||
data_file = config['DEFAULT']['data_file'] # guaranteed present by overlay
|
||||
if not TOKEN:
|
||||
print("[Config] WARNING: DISCORD_TOKEN not set (env). Bot will fail to log in.")
|
||||
print("[Config] WARNING: DISCORD_TOKEN is empty. The bot will fail to log in.")
|
||||
|
||||
bot = commands.Bot(command_prefix='!', intents=intents)
|
||||
bot.config = config
|
||||
bot.data_manager = DataManager(data_file)
|
||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
|
||||
# ---------- Self-check helpers ----------
|
||||
# Ensure data path exists and is seeded
|
||||
os.makedirs(os.path.dirname(DATA_FILE) or ".", exist_ok=True)
|
||||
if not os.path.exists(DATA_FILE):
|
||||
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
|
||||
async def _guild_selfcheck(g: discord.Guild, cfg):
|
||||
bot.data_manager = DataManager(DATA_FILE)
|
||||
|
||||
# ---------- Self-check: resolve from ENV first, then cfg_helper ----------
|
||||
def _resolve_channel_id(c, key: str) -> int:
|
||||
"""
|
||||
Resolve channel IDs from the runtime settings store (cfg), with a final
|
||||
fallback to legacy bot.config['DEFAULT'] if present. No SHAI_* env usage.
|
||||
"""
|
||||
try:
|
||||
v = int(c.int(key, 0))
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# legacy DEFAULT mapping (ConfigParser-like or our shim)
|
||||
v = int(getattr(c, "get", lambda *_: 0)(key, 0))
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
async def _guild_selfcheck(g: discord.Guild, c):
|
||||
problems = []
|
||||
|
||||
def _need_channel(id_key, *perms):
|
||||
raw = cfg.get(id_key)
|
||||
if not raw:
|
||||
cid = _resolve_channel_id(c, id_key)
|
||||
if not cid:
|
||||
problems.append(f"Missing config key: {id_key}")
|
||||
return
|
||||
try:
|
||||
cid = int(raw)
|
||||
except Exception:
|
||||
problems.append(f"Bad channel id for {id_key}: {raw}")
|
||||
return
|
||||
|
||||
ch = g.get_channel(cid)
|
||||
if not ch:
|
||||
problems.append(f"Channel not found: {id_key}={cid}")
|
||||
return
|
||||
|
||||
me = g.me
|
||||
p = ch.permissions_for(me)
|
||||
for perm in perms:
|
||||
@ -97,100 +110,100 @@ async def _guild_selfcheck(g: discord.Guild, cfg):
|
||||
_need_channel('mod_channel_id', 'read_messages', 'send_messages', 'add_reactions', 'read_message_history')
|
||||
_need_channel('modlog_channel_id', 'read_messages', 'send_messages')
|
||||
_need_channel('pirates_list_channel_id', 'read_messages', 'send_messages')
|
||||
_need_channel('dd_channel_id', 'read_messages', 'send_messages', 'read_message_history')
|
||||
|
||||
if problems:
|
||||
print(f"[SelfCheck:{g.name}]")
|
||||
for p in problems:
|
||||
print(" -", p)
|
||||
|
||||
async def _fetch_latest_from_rss(url: str):
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=8)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as sess:
|
||||
async with sess.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
return None, None
|
||||
text = await resp.text()
|
||||
# Gitea RSS structure: <rss><channel><item>…</item></channel></rss>
|
||||
root = ET.fromstring(text)
|
||||
item = root.find('./channel/item')
|
||||
if item is None:
|
||||
return None, None
|
||||
title = (item.findtext('title') or '').strip()
|
||||
link = (item.findtext('link') or '').strip()
|
||||
# Try to extract short sha from link tail if it's a commit URL
|
||||
sha = None
|
||||
if '/commit/' in link:
|
||||
sha = link.rsplit('/commit/', 1)[-1][:7]
|
||||
# Many Gitea feeds put the commit subject in <title>
|
||||
subject = title if title else None
|
||||
return subject, sha
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
# ---------- boot notice ----------
|
||||
|
||||
async def _post_boot_notice():
|
||||
|
||||
msg = f"Self-update and reboot successful! (v.{VERSION})"
|
||||
|
||||
ch_id_raw = bot.config['DEFAULT'].get('modlog_channel_id', '')
|
||||
try:
|
||||
ch_id = int(ch_id_raw) if ch_id_raw else 0
|
||||
except Exception:
|
||||
ch_id = 0
|
||||
if not ch_id:
|
||||
return
|
||||
for g in bot.guilds:
|
||||
ch = g.get_channel(ch_id)
|
||||
if ch:
|
||||
try:
|
||||
await ch.send(msg)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
# ---------- events ----------
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
import asyncio
|
||||
|
||||
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
|
||||
print("[Intents] members:", bot.intents.members, "/ message_content:", bot.intents.message_content, "/ voice_states:", bot.intents.voice_states)
|
||||
print("[Intents] members:", bot.intents.members,
|
||||
"/ message_content:", bot.intents.message_content,
|
||||
"/ voice_states:", bot.intents.voice_states)
|
||||
|
||||
await asyncio.gather(*[_guild_selfcheck(g, bot.config['DEFAULT']) for g in bot.guilds])
|
||||
env_cfg = cfg_helper(bot)
|
||||
|
||||
# Slash command sync
|
||||
# Per-guild permission sanity checks (env-aware)
|
||||
try:
|
||||
dev_gid = bot.config['DEFAULT'].get('dev_guild_id')
|
||||
if dev_gid:
|
||||
guild = bot.get_guild(int(dev_gid))
|
||||
if guild:
|
||||
synced = await bot.tree.sync(guild=guild)
|
||||
print(f"[Slash] Synced {len(synced)} commands to {guild.name}")
|
||||
else:
|
||||
synced = await bot.tree.sync()
|
||||
print(f"[Slash] Synced {len(synced)} commands globally (dev_guild_id not in cache)")
|
||||
await asyncio.gather(*[_guild_selfcheck(g, env_cfg) for g in bot.guilds])
|
||||
except Exception as e:
|
||||
print("[SelfCheck] failed:", repr(e))
|
||||
|
||||
# ---------- Slash command scope & sync ----------
|
||||
try:
|
||||
# env_cfg already exists above in on_ready()
|
||||
gid = env_cfg.int("home_guild_id", 0)
|
||||
|
||||
if gid > 0:
|
||||
print(f"[Slash] Mode: GUILD-ONLY → {gid}")
|
||||
|
||||
guild_obj = discord.Object(id=gid)
|
||||
|
||||
# Copy all currently-loaded global commands to HOME guild
|
||||
bot.tree.copy_global_to(guild=guild_obj)
|
||||
g_cmds = await bot.tree.sync(guild=guild_obj)
|
||||
g_names = ", ".join(f"/{c.name}" for c in g_cmds) if g_cmds else "(none)"
|
||||
print(f"[Slash] Synced {len(g_cmds)} commands to guild {gid}: {g_names}")
|
||||
|
||||
# Clear global so only guild-scoped remain
|
||||
bot.tree.clear_commands(guild=None)
|
||||
cleared = await bot.tree.sync() # push empty global set
|
||||
print(f"[Slash] Cleared global commands (now {len(cleared)}).")
|
||||
|
||||
# Debug: list actual state after sync
|
||||
try:
|
||||
global_cmds = await bot.tree.fetch_commands()
|
||||
print(f"[Slash] Global commands ({len(global_cmds)}): {', '.join(f'/{c.name}' for c in global_cmds) or '(none)'}")
|
||||
except Exception as e:
|
||||
print("[Slash] Failed to fetch global commands:", repr(e))
|
||||
|
||||
try:
|
||||
g_cmds = await bot.tree.fetch_commands(guild=guild_obj)
|
||||
print(f"[Slash] Guild {gid} commands ({len(g_cmds)}): {', '.join(f'/{c.name}' for c in g_cmds) or '(none)'}")
|
||||
except Exception as e:
|
||||
print(f"[Slash] Failed to fetch commands for guild {gid}:", repr(e))
|
||||
else:
|
||||
synced = await bot.tree.sync()
|
||||
print(f"[Slash] Synced {len(synced)} commands globally")
|
||||
print("[Slash] Mode: GLOBAL (HOME_GUILD_ID not set)")
|
||||
global_cmds = await bot.tree.sync()
|
||||
names = ", ".join(f"/{c.name}" for c in global_cmds) if global_cmds else "(none)"
|
||||
print(f"[Slash] Synced {len(global_cmds)} commands globally: {names}")
|
||||
|
||||
except Exception as e:
|
||||
print("[Slash] Sync failed:", repr(e))
|
||||
|
||||
# Boot notice in modlog
|
||||
await _post_boot_notice()
|
||||
|
||||
# ---------- Auto-discover extensions ----------
|
||||
|
||||
modules_path = pathlib.Path(__file__).parent / 'modules'
|
||||
modules_path = pathlib.Path(__file__).parent / "modules"
|
||||
extensions = []
|
||||
for folder in modules_path.iterdir():
|
||||
if folder.is_dir():
|
||||
for file in folder.glob('*.py'):
|
||||
if file.name == '__init__.py':
|
||||
continue
|
||||
extensions.append(f"modules.{folder.name}.{file.stem}")
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
# skip non-cog helpers under modules/common
|
||||
if folder.name == "common":
|
||||
continue
|
||||
for file in folder.glob("*.py"):
|
||||
if file.name == "__init__.py":
|
||||
continue
|
||||
extensions.append(f"modules.{folder.name}.{file.stem}")
|
||||
|
||||
def _install_signal_handlers(loop, bot_obj):
|
||||
def _graceful(*_):
|
||||
loop.create_task(bot_obj.close())
|
||||
for s in (signal.SIGTERM, signal.SIGINT):
|
||||
try:
|
||||
loop.add_signal_handler(s, _graceful)
|
||||
except NotImplementedError:
|
||||
pass # Windows
|
||||
|
||||
async def main():
|
||||
print(f"[STARTUP] ShaiWatcher booting v{VERSION}")
|
||||
async with bot:
|
||||
for ext in extensions:
|
||||
try:
|
||||
@ -198,7 +211,9 @@ async def main():
|
||||
print(f"[Modules] Loaded: {ext}")
|
||||
except Exception as e:
|
||||
print(f"[Modules] Failed to load {ext}:", repr(e))
|
||||
loop = asyncio.get_running_loop()
|
||||
_install_signal_handlers(loop, bot)
|
||||
await bot.start(TOKEN)
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
130
data_manager.py
@ -1,7 +1,9 @@
|
||||
# data_manager.py
|
||||
import json
|
||||
import threading
|
||||
import shutil
|
||||
import os
|
||||
import time
|
||||
from typing import Callable, Any
|
||||
|
||||
class DataManager:
|
||||
@ -10,41 +12,61 @@ class DataManager:
|
||||
self.lock = threading.Lock()
|
||||
self._data = self._load()
|
||||
|
||||
def _default_payload(self):
|
||||
return {
|
||||
'agreed_rules': [],
|
||||
'agreed_engagement': [],
|
||||
'agreed_nickname': [],
|
||||
'nick_same_confirmed': [],
|
||||
'nick_nudged': [],
|
||||
'nick_dm_map': [],
|
||||
'pirates': [],
|
||||
'modlog': [],
|
||||
'reports': [],
|
||||
'encounters': [],
|
||||
'vc_channels': [],
|
||||
'user_cards': [],
|
||||
'pirates_list_posts': [],
|
||||
'spicepay_prefs': [],
|
||||
'nick_verified': [],
|
||||
'nick_claim_pending': [],
|
||||
'nick_reviews': [],
|
||||
'rr_msg_channels': [],
|
||||
'_counters': {}, # key -> int (metrics)
|
||||
'_events_seen': {}, # optional (kept for other uses)
|
||||
'_counter_last_ts': {}, # key -> last increment unix_ts (timelock)
|
||||
}
|
||||
|
||||
def _load(self):
|
||||
try:
|
||||
with open(self.json_path, 'r') as f:
|
||||
return json.load(f)
|
||||
with open(self.json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("root is not an object")
|
||||
data.setdefault('_counters', {})
|
||||
data.setdefault('_events_seen', {})
|
||||
data.setdefault('_counter_last_ts', {})
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
default = {
|
||||
'agreed_rules': [],
|
||||
'agreed_engagement': [],
|
||||
'agreed_nickname': [],
|
||||
'nick_same_confirmed': [],
|
||||
'nick_nudged': [],
|
||||
'nick_dm_map': [],
|
||||
'pirates': [],
|
||||
'modlog': [],
|
||||
'reports': [],
|
||||
'encounters': [],
|
||||
'vc_channels': [],
|
||||
'user_cards': [],
|
||||
'pirates_list_posts': [],
|
||||
'spicepay_prefs': [],
|
||||
'nick_verified': [],
|
||||
'nick_claim_pending': [],
|
||||
'nick_reviews': [],
|
||||
'rr_msg_channels': [],
|
||||
}
|
||||
default = self._default_payload()
|
||||
self._save(default)
|
||||
return default
|
||||
except Exception:
|
||||
# Backup the broken file if it exists, then start fresh
|
||||
try:
|
||||
if os.path.exists(self.json_path):
|
||||
shutil.copy2(self.json_path, self.json_path + ".corrupt.bak")
|
||||
except Exception:
|
||||
pass
|
||||
default = self._default_payload()
|
||||
self._save(default)
|
||||
return default
|
||||
|
||||
def _safe_write(self, data: dict):
|
||||
# ensure parent dir exists
|
||||
os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
|
||||
tmp = self.json_path + ".tmp"
|
||||
with open(tmp, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
# backup current file (best-effort)
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
if os.path.exists(self.json_path):
|
||||
try:
|
||||
shutil.copy2(self.json_path, self.json_path + ".bak")
|
||||
@ -53,9 +75,9 @@ class DataManager:
|
||||
os.replace(tmp, self.json_path)
|
||||
|
||||
def _save(self, data: dict):
|
||||
# single place to write (atomic replace + rolling .bak)
|
||||
self._safe_write(data)
|
||||
|
||||
# ------------- list helpers -------------
|
||||
def get(self, category: str):
|
||||
with self.lock:
|
||||
return list(self._data.get(category, []))
|
||||
@ -72,15 +94,10 @@ class DataManager:
|
||||
self._save(self._data)
|
||||
|
||||
def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool:
|
||||
"""
|
||||
Atomically find one item in `category` matching predicate and update it with `updater`.
|
||||
Returns True if an item was updated, else False.
|
||||
"""
|
||||
with self.lock:
|
||||
arr = self._data.get(category, [])
|
||||
for idx, item in enumerate(arr):
|
||||
if predicate(item):
|
||||
# Copy → mutate → save back
|
||||
new_item = dict(item)
|
||||
new_item = updater(new_item) or new_item
|
||||
arr[idx] = new_item
|
||||
@ -88,3 +105,52 @@ class DataManager:
|
||||
self._save(self._data)
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------- counters (plain) -------------
|
||||
def incr_counter(self, key: str, by: int = 1) -> int:
|
||||
with self.lock:
|
||||
c = self._data.setdefault('_counters', {})
|
||||
c[key] = int(c.get(key, 0)) + int(by)
|
||||
self._save(self._data)
|
||||
return c[key]
|
||||
|
||||
def get_counter(self, key: str) -> int:
|
||||
with self.lock:
|
||||
return int(self._data.get('_counters', {}).get(key, 0))
|
||||
|
||||
def get_all_counters(self, prefix: str = "") -> dict[str, int]:
|
||||
with self.lock:
|
||||
c = dict(self._data.get('_counters', {}))
|
||||
return {k: v for k, v in c.items() if (not prefix or k.startswith(prefix))}
|
||||
|
||||
# ------------- counters (timelocked) -------------
|
||||
def incr_counter_timelocked(self, counter_key: str, window_sec: float = 1.0) -> int | None:
|
||||
"""
|
||||
Increment `counter_key` at most once per `window_sec`.
|
||||
Returns the new value if incremented, or None if suppressed by the timelock.
|
||||
"""
|
||||
now = time.time()
|
||||
with self.lock:
|
||||
last_map = self._data.setdefault('_counter_last_ts', {})
|
||||
last = float(last_map.get(counter_key, 0.0))
|
||||
if now - last < float(window_sec):
|
||||
# within lock window -> ignore
|
||||
return None
|
||||
|
||||
# increment and stamp
|
||||
counters = self._data.setdefault('_counters', {})
|
||||
counters[counter_key] = int(counters.get(counter_key, 0)) + 1
|
||||
last_map[counter_key] = now
|
||||
|
||||
# opportunistic pruning for very old stamps (keeps file smaller)
|
||||
if len(last_map) > 5000:
|
||||
cutoff = now - (window_sec * 60)
|
||||
for k in list(last_map.keys()):
|
||||
try:
|
||||
if float(last_map.get(k, 0.0)) < cutoff:
|
||||
last_map.pop(k, None)
|
||||
except Exception:
|
||||
last_map.pop(k, None)
|
||||
|
||||
self._save(self._data)
|
||||
return counters[counter_key]
|
||||
|
148
dev/offline_preview.py
Normal file
@ -0,0 +1,148 @@
|
||||
# offline_preview.py
|
||||
"""
|
||||
ShaiWatcher offline preview (Discord-less)
|
||||
|
||||
Run from anywhere:
|
||||
# optional (forces root if your layout is odd)
|
||||
# export SHAI_PROJECT_ROOT=/absolute/path/to/shaiwatcher
|
||||
# export SHAI_DOCS_HOST=127.0.0.1
|
||||
# export SHAI_DOCS_PORT=8910
|
||||
# export SHAI_OFFLINE=1
|
||||
python3 offline_preview.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import pathlib
|
||||
import traceback
|
||||
|
||||
VERSION = "offline-preview-3"
|
||||
|
||||
# ---------- repo root discovery ----------
|
||||
def _find_project_root() -> pathlib.Path:
|
||||
cand = []
|
||||
env = os.environ.get("SHAI_PROJECT_ROOT")
|
||||
if env:
|
||||
cand.append(pathlib.Path(env).resolve())
|
||||
here = pathlib.Path(__file__).resolve().parent
|
||||
cand.extend([
|
||||
pathlib.Path.cwd().resolve(), # current working dir
|
||||
here, # folder containing this file
|
||||
here.parent, # one level up
|
||||
here.parent.parent, # two levels up
|
||||
])
|
||||
# Also walk upwards from CWD a few levels to be forgiving
|
||||
cur = pathlib.Path.cwd().resolve()
|
||||
for _ in range(5):
|
||||
cand.append(cur)
|
||||
cur = cur.parent
|
||||
|
||||
tried = []
|
||||
for c in cand:
|
||||
tried.append(str(c))
|
||||
if (c / "modules").is_dir() and (c / "modules" / "common").is_dir():
|
||||
return c
|
||||
|
||||
raise FileNotFoundError(
|
||||
"Could not locate project root with a 'modules/common' folder.\n"
|
||||
f"Tried:\n - " + "\n - ".join(tried) +
|
||||
"\nTip: set SHAI_PROJECT_ROOT=/absolute/path/to/repo"
|
||||
)
|
||||
|
||||
PROJECT_ROOT = _find_project_root()
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# ---------- now safe to import project modules ----------
|
||||
import discord # type: ignore
|
||||
from discord.ext import commands # type: ignore
|
||||
|
||||
# Optional: your config helper if cogs expect it to exist
|
||||
try:
|
||||
from modules.common.settings import cfg as cfg_helper # noqa: F401
|
||||
except Exception as e:
|
||||
print("[OFFLINE] Warning: couldn't import cfg helper:", repr(e))
|
||||
|
||||
def _discover_extensions(project_root: pathlib.Path):
|
||||
modules_path = project_root / "modules"
|
||||
exts = []
|
||||
for folder in modules_path.iterdir():
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
if folder.name == "common": # match your prod loader
|
||||
continue
|
||||
for file in folder.glob("*.py"):
|
||||
if file.name == "__init__.py":
|
||||
continue
|
||||
exts.append(f"modules.{folder.name}.{file.stem}")
|
||||
return exts
|
||||
|
||||
async def main():
|
||||
print(f"[OFFLINE] ShaiWatcher offline preview v{VERSION}")
|
||||
print(f"[OFFLINE] Project root -> {PROJECT_ROOT}")
|
||||
|
||||
# Keep intents minimal; we never connect anyway
|
||||
intents = discord.Intents.none()
|
||||
intents.guilds = True
|
||||
|
||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
|
||||
# Mark environment as offline for any cogs that check it
|
||||
os.environ.setdefault("SHAI_OFFLINE", "1")
|
||||
# Bind docs to localhost by default while testing
|
||||
os.environ.setdefault("SHAI_DOCS_HOST", "127.0.0.1")
|
||||
os.environ.setdefault("SHAI_DOCS_PORT", "8910")
|
||||
os.environ.setdefault("SHAI_DOCS_TITLE", "ShaiWatcher (Offline Preview)")
|
||||
|
||||
# Optional: isolate data file so we don't touch prod paths
|
||||
data_file = os.environ.get("SHAI_DATA", str(PROJECT_ROOT / ".offline_data.json"))
|
||||
try:
|
||||
from data_manager import DataManager # if your project has this at root
|
||||
os.makedirs(os.path.dirname(data_file) or ".", exist_ok=True)
|
||||
if not os.path.exists(data_file):
|
||||
with open(data_file, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
bot.data_manager = DataManager(data_file)
|
||||
print(f"[OFFLINE] DATA_FILE -> {data_file}")
|
||||
except Exception as e:
|
||||
print("[OFFLINE] DataManager unavailable/failed:", repr(e))
|
||||
|
||||
os.environ.setdefault("SHAI_OFFLINE", "1") # before loading cogs
|
||||
|
||||
# Load extensions exactly like prod
|
||||
failures = 0
|
||||
for ext in _discover_extensions(PROJECT_ROOT):
|
||||
try:
|
||||
await bot.load_extension(ext)
|
||||
print(f"[Modules] Loaded: {ext}")
|
||||
except Exception as e:
|
||||
failures += 1
|
||||
print(f"[Modules] Failed to load {ext}: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
if failures:
|
||||
print(f"[OFFLINE] Loaded with {failures} module error(s). See logs above.")
|
||||
|
||||
docs = bot.get_cog("DocsSite")
|
||||
if docs and hasattr(docs, "force_ready"):
|
||||
docs.force_ready(True)
|
||||
|
||||
# Make is_ready() == True so DocsSite serves immediately
|
||||
try:
|
||||
# discord.py sets this in login/READY; we emulate it
|
||||
if not hasattr(bot, "_ready") or bot._ready is None: # type: ignore[attr-defined]
|
||||
bot._ready = asyncio.Event() # type: ignore[attr-defined]
|
||||
bot._ready.set() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("[OFFLINE] Docs: http://%s:%s/"
|
||||
% (os.environ.get("SHAI_DOCS_HOST", "0.0.0.0"),
|
||||
os.environ.get("SHAI_DOCS_PORT", "8911")))
|
||||
print("[OFFLINE] This runner does NOT connect to Discord.")
|
||||
|
||||
# Idle forever; DocsSite runs in its own daemon thread
|
||||
await asyncio.Event().wait()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
@ -1,75 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
shaiwatcher:
|
||||
# Build the image from your repo (Portainer Git deploy will use this)
|
||||
build:
|
||||
context: .
|
||||
dockerfile: dockerfile # <- your file is named 'dockerfile'
|
||||
restart: unless-stopped
|
||||
|
||||
# Everything configurable shows up in Portainer's env UI
|
||||
environment:
|
||||
# --- Required token (set in Portainer UI) ---
|
||||
DISCORD_TOKEN: ${DISCORD_TOKEN}
|
||||
|
||||
# --- Paths ---
|
||||
SHAI_DATA_FILE: /data/data.json
|
||||
|
||||
# --- Reaction gating messages ---
|
||||
SHAI_RULES_MESSAGE_ID: "1396831304460402738"
|
||||
SHAI_ENGAGEMENT_MESSAGE_ID: "1397668657143742574"
|
||||
SHAI_NICKNAME_MESSAGE_ID: "1403513532108247141"
|
||||
|
||||
# --- Roles ---
|
||||
SHAI_RULES_ROLE_ID: "1403146506596253817"
|
||||
SHAI_ENGAGEMENT_ROLE_ID: "1403146604894224458"
|
||||
SHAI_FULL_ACCESS_ROLE_ID: "1403146645121667082"
|
||||
SHAI_ADMIN_ROLE_ID: "1402000098476425246"
|
||||
SHAI_FIELD_MOD_ROLE_ID: "1402001335041261681"
|
||||
SHAI_INTEL_MOD_ROLE_ID: "1402001000327417946"
|
||||
SHAI_MODERATOR_ROLE_ID: "1396828779015573598"
|
||||
|
||||
# --- Channels ---
|
||||
SHAI_MOD_CHANNEL_ID: "1403139701522698240"
|
||||
SHAI_MODLOG_CHANNEL_ID: "1403146993198436627"
|
||||
SHAI_USERSLIST_CHANNEL_ID: "1403146908385542215"
|
||||
SHAI_REPORT_CHANNEL_ID: "1403147077285843034"
|
||||
SHAI_PIRATES_LIST_CHANNEL_ID: "1403147077285843034"
|
||||
|
||||
# --- Auto-VC ---
|
||||
SHAI_TRIGGER_CHANNEL_ID: "1403139044174594190"
|
||||
SHAI_AUTO_VC_CATEGORY_ID: "1403138882958266428"
|
||||
SHAI_VC_NAME_PREFIX: "DD Crew " # trailing space intentional
|
||||
SHAI_AUTO_VC_CLEANUP_DELAY: "30"
|
||||
|
||||
# --- Threat weights ---
|
||||
SHAI_THREAT_W_KILL: "0.30"
|
||||
SHAI_THREAT_W_DESTRUCTION: "0.40"
|
||||
SHAI_THREAT_W_GROUP: "0.20"
|
||||
SHAI_THREAT_W_SKILL: "0.10"
|
||||
SHAI_THREAT_GROUP_THRESHOLD: "3"
|
||||
SHAI_THREAT_MIN_SAMPLES_FOR_STATS: "3"
|
||||
|
||||
# --- Misc toggles ---
|
||||
SHAI_RELEASE_VERSION: "false"
|
||||
SHAI_NICK_NUDGE_LOOP_ENABLED: "false"
|
||||
SHAI_HOME_GUILD_ID: "1396826999095427253"
|
||||
SHAI_USER_CARDS_CRON_ENABLED: "true"
|
||||
|
||||
# --- SpicePay defaults ---
|
||||
SHAI_SPICEPAY_LSR_CUT_PERCENT: "10"
|
||||
SHAI_SPICEPAY_BASE_WEIGHT: "25"
|
||||
SHAI_SPICEPAY_CARRIER_BONUS: "12.5"
|
||||
SHAI_SPICEPAY_CRAWLER_BONUS: "12.5"
|
||||
|
||||
# --- Optional emojis (IDs) ---
|
||||
SHAI_EMOJI_MELANGE_ID: "1401965356775510210"
|
||||
SHAI_EMOJI_SAND_ID: "1401965308805255310"
|
||||
SHAI_EMOJI_CARRIER_CRAWLER_ID: "1402285453037666386"
|
||||
|
||||
volumes:
|
||||
- shaiwatcher_data:/data # persistent data.json lives here
|
||||
|
||||
volumes:
|
||||
shaiwatcher_data:
|
@ -1,30 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Defaults (can be overridden)
|
||||
: "${SHAI_CONFIG:=/config/settings.conf}"
|
||||
: "${SHAI_DATA:=/data/data.json}"
|
||||
|
||||
# Seed /config/settings.conf on first run if it doesn't exist
|
||||
if [ ! -f "$SHAI_CONFIG" ]; then
|
||||
mkdir -p "$(dirname "$SHAI_CONFIG")"
|
||||
if [ -f /app/example/settings.conf ]; then
|
||||
cp /app/example/settings.conf "$SHAI_CONFIG"
|
||||
echo "Seeded default settings to $SHAI_CONFIG"
|
||||
else
|
||||
# Fall back: generate minimal config so the app can boot
|
||||
cat > "$SHAI_CONFIG" <<EOF
|
||||
[DEFAULT]
|
||||
data_file = ${SHAI_DATA}
|
||||
EOF
|
||||
echo "Generated minimal $SHAI_CONFIG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p "$(dirname "$SHAI_DATA")"
|
||||
|
||||
# Make path visible to the app (bot.py will still read the INI)
|
||||
export SHAI_CONFIG SHAI_DATA
|
||||
|
||||
exec "$@"
|
26
dockerfile
@ -1,26 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# deps first
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# app code
|
||||
COPY . /app
|
||||
|
||||
# runtime dirs + seed default config path (actual seeding done in entrypoint)
|
||||
RUN mkdir -p /config /data
|
||||
|
||||
# runtime env defaults (can be overridden by compose/env)
|
||||
ENV SHAI_CONFIG=/config/settings.conf \
|
||||
SHAI_DATA=/data/data.json
|
||||
|
||||
# small, explicit entrypoint
|
||||
COPY docker-entrypoint.sh /usr/local/bin/entrypoint
|
||||
RUN chmod +x /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT ["entrypoint"]
|
||||
CMD ["python","-u","/app/bot.py"]
|
@ -1,5 +0,0 @@
|
||||
DISCORD_TOKEN={DISCORD_BOT_TOKEN}
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_PUBLIC_KEY=
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
27
mod_perms.py
@ -2,12 +2,15 @@
|
||||
import re
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from modules.common.settings import cfg # ENV-first config helper
|
||||
|
||||
def _parse_ids(raw: str):
|
||||
ids = []
|
||||
if not raw:
|
||||
return ids
|
||||
for tok in re.split(r'[,\s]+', raw.strip()):
|
||||
if not tok:
|
||||
continue
|
||||
try:
|
||||
ids.append(int(tok))
|
||||
except Exception:
|
||||
@ -15,20 +18,20 @@ def _parse_ids(raw: str):
|
||||
return ids
|
||||
|
||||
def get_mod_role_ids(bot: commands.Bot):
|
||||
cfg = bot.config['DEFAULT']
|
||||
# read individually; allow comma-separated in any field for flexibility
|
||||
# Read from ENV/INI via helper; allow comma-separated lists in any field
|
||||
reader = cfg(bot)
|
||||
keys = ["admin_role_id", "field_mod_role_id", "intel_mod_role_id", "moderator_role_id"]
|
||||
ids = []
|
||||
collected = []
|
||||
for k in keys:
|
||||
raw = cfg.get(k, "")
|
||||
for tok in re.split(r"[,\s]+", raw.strip()):
|
||||
if not tok:
|
||||
continue
|
||||
try:
|
||||
ids.append(int(tok))
|
||||
except Exception:
|
||||
pass
|
||||
return ids
|
||||
collected.extend(_parse_ids(reader.get(k, "")))
|
||||
# dedupe while preserving order
|
||||
seen = set()
|
||||
unique = []
|
||||
for i in collected:
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
unique.append(i)
|
||||
return unique
|
||||
|
||||
def is_moderator_member(member: discord.Member, bot: commands.Bot) -> bool:
|
||||
if not isinstance(member, discord.Member):
|
||||
|
0
modules/admin/__init__.py
Normal file
168
modules/admin/shaiadmin.py
Normal file
@ -0,0 +1,168 @@
|
||||
# modules/admin/shaiadmin.py
|
||||
import io
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
from modules.common.settings import (
|
||||
cfg, SETTINGS_SCHEMA, settings_path, settings_get_all,
|
||||
settings_set, settings_reset, settings_import_bulk, ValidationError,
|
||||
)
|
||||
from mod_perms import is_moderator_member # keep if you want mods as managers
|
||||
|
||||
def _user_has_role_ids(member: discord.Member, role_ids: List[int]) -> bool:
|
||||
if not isinstance(member, discord.Member) or not role_ids:
|
||||
return False
|
||||
rset = set(role_ids)
|
||||
return any(r.id in rset for r in member.roles)
|
||||
|
||||
async def _is_owner(bot: commands.Bot, user: discord.abc.User) -> bool:
|
||||
try:
|
||||
return await bot.is_owner(user)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _get_admin_lists(bot: commands.Bot) -> Dict[str, List[int]]:
|
||||
r = cfg(bot)
|
||||
users, roles = [], []
|
||||
try:
|
||||
users = json.loads(r.get("admin_user_ids", "[]"))
|
||||
except Exception:
|
||||
users = []
|
||||
try:
|
||||
roles = json.loads(r.get("admin_role_ids", "[]"))
|
||||
except Exception:
|
||||
roles = []
|
||||
return {"users": users, "roles": roles}
|
||||
|
||||
async def _check_admin(inter: discord.Interaction) -> bool:
|
||||
bot: commands.Bot = inter.client # type: ignore
|
||||
user = inter.user
|
||||
if await _is_owner(bot, user):
|
||||
return True
|
||||
if isinstance(user, discord.Member):
|
||||
lists = _get_admin_lists(bot)
|
||||
if user.id in set(lists["users"]):
|
||||
return True
|
||||
if _user_has_role_ids(user, lists["roles"]):
|
||||
return True
|
||||
if is_moderator_member(user, bot): # optional; remove if not desired
|
||||
return True
|
||||
if not inter.response.is_done():
|
||||
await inter.response.send_message("You don’t have permission to use `/shaiadmin`.", ephemeral=True)
|
||||
else:
|
||||
await inter.followup.send("You don’t have permission to use `/shaiadmin`.", ephemeral=True)
|
||||
return False
|
||||
|
||||
|
||||
class ShaiAdminCog(commands.Cog):
|
||||
"""Runtime settings administration (file-backed)."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
# Guild-only group; prefix description with [ADMIN]
|
||||
shaiadmin = app_commands.Group(
|
||||
name="shaiadmin",
|
||||
description="[ADMIN] Owner/approved-only settings manager.",
|
||||
guild_only=True,
|
||||
)
|
||||
|
||||
# ---- bound coroutine for autocomplete ----
|
||||
async def ac_setting_keys(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]:
|
||||
cur = (current or "").lower()
|
||||
keys = [k for k in sorted(SETTINGS_SCHEMA.keys()) if cur in k]
|
||||
return [app_commands.Choice(name=k, value=k) for k in keys[:25]]
|
||||
|
||||
# /shaiadmin set
|
||||
@shaiadmin.command(name="set", description="[ADMIN] Set a setting (validated, persisted, applied).")
|
||||
@app_commands.describe(setting_name="Which setting to change", value="New value (type depends on setting)")
|
||||
@app_commands.autocomplete(setting_name=ac_setting_keys)
|
||||
async def set_value(self, inter: discord.Interaction, setting_name: str, value: str):
|
||||
if not await _check_admin(inter):
|
||||
return
|
||||
await inter.response.defer(ephemeral=True, thinking=True)
|
||||
setting_name = setting_name.lower().strip()
|
||||
try:
|
||||
changed = settings_set(setting_name, value)
|
||||
await inter.followup.send(
|
||||
f"✅ `{setting_name}` updated and applied." if changed else "ℹ️ No change.",
|
||||
ephemeral=True,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
await inter.followup.send(f"❌ {ve}", ephemeral=True)
|
||||
except Exception as e:
|
||||
await inter.followup.send(f"❌ Failed to set `{setting_name}`: {e!r}", ephemeral=True)
|
||||
|
||||
# /shaiadmin unset
|
||||
@shaiadmin.command(name="unset", description="[ADMIN] Reset/unset a setting to its default.")
|
||||
@app_commands.describe(setting_name="Which setting to reset")
|
||||
@app_commands.autocomplete(setting_name=ac_setting_keys)
|
||||
async def unset_value(self, inter: discord.Interaction, setting_name: str):
|
||||
if not await _check_admin(inter):
|
||||
return
|
||||
await inter.response.defer(ephemeral=True, thinking=True)
|
||||
setting_name = setting_name.lower().strip()
|
||||
try:
|
||||
settings_reset(setting_name)
|
||||
await inter.followup.send(f"✅ `{setting_name}` reset to default and applied.", ephemeral=True)
|
||||
except ValidationError as ve:
|
||||
await inter.followup.send(f"❌ {ve}", ephemeral=True)
|
||||
except Exception as e:
|
||||
await inter.followup.send(f"❌ Failed to reset `{setting_name}`: {e!r}", ephemeral=True)
|
||||
|
||||
# /shaiadmin settings (download/upload)
|
||||
settings = app_commands.Group(
|
||||
name="settings",
|
||||
description="[ADMIN] Download or upload the full settings JSON.",
|
||||
parent=shaiadmin,
|
||||
guild_only=True,
|
||||
)
|
||||
|
||||
@settings.command(name="download", description="[ADMIN] Download the current settings.json")
|
||||
async def download(self, inter: discord.Interaction):
|
||||
if not await _check_admin(inter):
|
||||
return
|
||||
await inter.response.defer(ephemeral=True, thinking=True)
|
||||
data = settings_get_all()
|
||||
buf = io.BytesIO(json.dumps(data, indent=2, ensure_ascii=False).encode("utf-8"))
|
||||
buf.seek(0)
|
||||
await inter.followup.send(
|
||||
content=f"📦 Current settings from `{settings_path()}`",
|
||||
file=discord.File(buf, filename="settings.json"),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
@settings.command(name="upload", description="[ADMIN] Upload and apply a settings.json")
|
||||
@app_commands.describe(file="A JSON file exported by /shaiadmin settings download (or matching the schema).")
|
||||
async def upload(self, inter: discord.Interaction, file: discord.Attachment):
|
||||
if not await _check_admin(inter):
|
||||
return
|
||||
await inter.response.defer(ephemeral=True, thinking=True)
|
||||
if not file or not file.filename.lower().endswith(".json"):
|
||||
await inter.followup.send("Please attach a `.json` file.", ephemeral=True)
|
||||
return
|
||||
try:
|
||||
raw = await file.read()
|
||||
obj = json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
await inter.followup.send("❌ Invalid JSON file.", ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
changed_keys = settings_import_bulk(obj)
|
||||
await inter.followup.send(
|
||||
f"✅ Uploaded and applied `{len(changed_keys)}` keys: {', '.join(sorted(changed_keys))}."
|
||||
if changed_keys else "ℹ️ No changes detected.",
|
||||
ephemeral=True,
|
||||
)
|
||||
except ValidationError as ve:
|
||||
await inter.followup.send(f"❌ {ve}", ephemeral=True)
|
||||
except Exception as e:
|
||||
await inter.followup.send(f"❌ Upload failed: {e!r}", ephemeral=True)
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(ShaiAdminCog(bot))
|
@ -3,6 +3,9 @@ import asyncio
|
||||
import time
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
from modules.common.settings import cfg # ENV-first config helper
|
||||
from mod_perms import require_mod_ctx, require_mod_interaction # <- use project mod perms
|
||||
|
||||
def now() -> float:
|
||||
return time.time()
|
||||
@ -17,25 +20,26 @@ class AutoVCCog(commands.Cog):
|
||||
|
||||
Admin commands:
|
||||
/avc_status -> show current state
|
||||
/avc_cleanup_now -> run a cleanup/renumber pass now
|
||||
/avc_renumber -> renumber without deleting
|
||||
/avc_cleanup_now -> [MOD] run a cleanup/renumber pass now
|
||||
/avc_renumber -> [MOD] renumber without deleting
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
cfg = bot.config['DEFAULT']
|
||||
r = cfg(bot)
|
||||
|
||||
# Config
|
||||
self.trigger_id = int(cfg['trigger_channel_id'])
|
||||
self.category_id = int(cfg['auto_vc_category_id'])
|
||||
self.prefix = cfg['vc_name_prefix']
|
||||
self.delay = int(cfg.get('auto_vc_cleanup_delay', 30))
|
||||
self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0
|
||||
# Config (ENV/INI via helper; safe defaults)
|
||||
self.trigger_id = r.int('trigger_channel_id', 0)
|
||||
self.category_id = r.int('auto_vc_category_id', 0)
|
||||
self.prefix = r.get('vc_name_prefix', 'Room')
|
||||
self.delay = r.int('auto_vc_cleanup_delay', 30)
|
||||
self.modlog_channel_id = r.int('modlog_channel_id', 0)
|
||||
|
||||
# State
|
||||
self.empty_since: dict[int, float] = {} # channel_id -> ts when became empty
|
||||
self.empty_since: dict[int, float] = {} # channel_id -> ts when became empty
|
||||
self._vc_cooldowns: dict[int, float] = {} # user_id -> ts last created (anti-spam)
|
||||
self._create_lock = asyncio.Lock()
|
||||
self._ops_lock = asyncio.Lock() # serialize admin ops vs sweeper
|
||||
|
||||
# Background sweeper
|
||||
self._task = asyncio.create_task(self._sweeper())
|
||||
@ -92,6 +96,8 @@ class AutoVCCog(commands.Cog):
|
||||
|
||||
async def _cleanup_pass(self, guild: discord.Guild):
|
||||
"""Delete empty tracked channels that exceeded delay and renumber."""
|
||||
if not self.category_id:
|
||||
return
|
||||
cat = guild.get_channel(self.category_id)
|
||||
if not cat:
|
||||
return
|
||||
@ -138,8 +144,10 @@ class AutoVCCog(commands.Cog):
|
||||
await self.bot.wait_until_ready()
|
||||
while not self.bot.is_closed():
|
||||
try:
|
||||
for guild in self.bot.guilds:
|
||||
await self._cleanup_pass(guild)
|
||||
# Serialize with admin ops
|
||||
async with self._ops_lock:
|
||||
for guild in self.bot.guilds:
|
||||
await self._cleanup_pass(guild)
|
||||
except Exception as e:
|
||||
print("[auto_vc] sweeper loop error:", repr(e))
|
||||
await asyncio.sleep(30)
|
||||
@ -148,6 +156,9 @@ class AutoVCCog(commands.Cog):
|
||||
|
||||
async def _spawn_and_move(self, member: discord.Member):
|
||||
guild = member.guild
|
||||
if not self.category_id:
|
||||
await self._log(guild, "⚠️ auto_vc_category_id not configured; cannot create rooms.")
|
||||
return
|
||||
cat = guild.get_channel(self.category_id)
|
||||
if not cat:
|
||||
await self._log(guild, "⚠️ auto_vc_category_id not found; cannot create rooms.")
|
||||
@ -195,7 +206,7 @@ class AutoVCCog(commands.Cog):
|
||||
guild = member.guild
|
||||
|
||||
# Create on trigger join (with 5s per-user cooldown)
|
||||
if after.channel and after.channel.id == self.trigger_id:
|
||||
if self.trigger_id and after.channel and after.channel.id == self.trigger_id:
|
||||
last = self._vc_cooldowns.get(member.id, 0.0)
|
||||
if now() - last < 5.0:
|
||||
return
|
||||
@ -206,7 +217,7 @@ class AutoVCCog(commands.Cog):
|
||||
print("[auto_vc] spawn/move failed:", repr(e))
|
||||
|
||||
# Mark empties immediately on leave
|
||||
if before.channel:
|
||||
if before.channel and self.category_id:
|
||||
ch = before.channel
|
||||
if ch.category_id == self.category_id:
|
||||
rec = self._find_record(guild.id, ch.id)
|
||||
@ -215,13 +226,15 @@ class AutoVCCog(commands.Cog):
|
||||
|
||||
# ------------- admin commands -------------
|
||||
|
||||
@commands.hybrid_command(name="avc_status", description="Show Auto-VC status for this guild")
|
||||
@commands.has_permissions(manage_guild=True)
|
||||
async def avc_status(self, ctx: commands.Context):
|
||||
g = ctx.guild
|
||||
@app_commands.command(name="avc_status", description="Show Auto-VC status for this guild")
|
||||
async def avc_status(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
|
||||
g = interaction.guild
|
||||
recs = sorted(self._vc_records(g.id), key=lambda r: r.get('created_ts', 0))
|
||||
lines = [
|
||||
f"Trigger: <#{self.trigger_id}> | Category: <#{self.category_id}> | Prefix: `{self.prefix}` | Delay: {self.delay}s"
|
||||
f"Trigger: <#{self.trigger_id or 0}> | Category: <#{self.category_id or 0}> | Prefix: `{self.prefix}` | Delay: {self.delay}s"
|
||||
]
|
||||
for idx, rec in enumerate(recs, start=1):
|
||||
ch = g.get_channel(rec['channel_id'])
|
||||
@ -234,21 +247,35 @@ class AutoVCCog(commands.Cog):
|
||||
t = self.empty_since.get(rec['channel_id'])
|
||||
tail = f" | idle {int(now()-t)}s" if t and (not ch or (ch and not ch.members)) else ""
|
||||
lines.append(f"- #{idx}: {name} — {state}{tail}")
|
||||
|
||||
msg = "Auto-VC status:\n" + "\n".join(lines) if lines else "No Auto-VC rooms tracked."
|
||||
await ctx.reply(msg)
|
||||
await interaction.response.send_message(msg)
|
||||
|
||||
@commands.hybrid_command(name="avc_cleanup_now", description="Run an immediate cleanup pass (delete idle rooms & renumber)")
|
||||
@commands.has_permissions(manage_guild=True)
|
||||
async def avc_cleanup_now(self, ctx: commands.Context):
|
||||
await self._cleanup_pass(ctx.guild)
|
||||
await ctx.reply("Cleanup pass complete.")
|
||||
@app_commands.command(name="avc_cleanup_now", description="[MOD] Run an immediate cleanup pass (delete idle rooms & renumber)")
|
||||
async def avc_cleanup_now(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
|
||||
return
|
||||
|
||||
@commands.hybrid_command(name="avc_renumber", description="Force a renumber of tracked rooms")
|
||||
@commands.has_permissions(manage_guild=True)
|
||||
async def avc_renumber(self, ctx: commands.Context):
|
||||
await self._renumber(ctx.guild)
|
||||
await ctx.reply("Renumbered.")
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
async with self._ops_lock:
|
||||
await self._cleanup_pass(interaction.guild)
|
||||
await self._log(interaction.guild, f"🧹 Cleanup pass invoked by {interaction.user.mention}")
|
||||
await interaction.followup.send("Cleanup pass complete.", ephemeral=True)
|
||||
|
||||
@app_commands.command(name="avc_renumber", description="[MOD] Force a renumber of tracked rooms")
|
||||
async def avc_renumber(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
async with self._ops_lock:
|
||||
await self._renumber(interaction.guild)
|
||||
await self._log(interaction.guild, f"🔢 Renumber invoked by {interaction.user.mention}")
|
||||
await interaction.followup.send("Renumbered.", ephemeral=True)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(AutoVCCog(bot))
|
||||
|
296
modules/common/boot_notice.py
Normal file
@ -0,0 +1,296 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse, urlencode
|
||||
|
||||
import discord
|
||||
import aiohttp
|
||||
|
||||
from modules.common.settings import cfg
|
||||
|
||||
# ---------- Version helpers ----------
|
||||
|
||||
_VERSION_RE = re.compile(r'\b\d+\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9]+)?\b')
|
||||
|
||||
def _extract_version(subject: str) -> str | None:
|
||||
if not subject:
|
||||
return None
|
||||
m = _VERSION_RE.search(subject)
|
||||
return m.group(0) if m else None
|
||||
|
||||
def _split_subject_body(full_message: str) -> tuple[str | None, str | None]:
|
||||
if not full_message:
|
||||
return None, None
|
||||
lines = [ln.rstrip() for ln in full_message.splitlines()]
|
||||
subject = None
|
||||
i = 0
|
||||
while i < len(lines) and subject is None:
|
||||
if lines[i].strip():
|
||||
subject = lines[i].strip()
|
||||
i += 1
|
||||
body = '\n'.join(lines[i:]).strip() if i < len(lines) else ''
|
||||
return subject or None, (body or None)
|
||||
|
||||
def _cmp_versions(a: str | None, b: str | None) -> int:
|
||||
"""Compare 1.2.3.4.a2 style; if either missing, treat as equal (0)."""
|
||||
if not a or not b:
|
||||
return 0
|
||||
pa, pb = a.split('.'), b.split('.')
|
||||
while len(pa) < 5: pa.append('0')
|
||||
while len(pb) < 5: pb.append('0')
|
||||
|
||||
def key(x: str):
|
||||
if x.isdigit():
|
||||
return (int(x), '', 1)
|
||||
m = re.match(r'(\d+)(.*)', x)
|
||||
if m:
|
||||
return (int(m.group(1)), m.group(2), 2)
|
||||
return (0, x, 3)
|
||||
|
||||
for xa, xb in zip(pa, pb):
|
||||
ka, kb = key(xa), key(xb)
|
||||
if ka[0] != kb[0]:
|
||||
return 1 if ka[0] > kb[0] else -1
|
||||
if ka[2] != kb[2]:
|
||||
return 1 if ka[2] < kb[2] else -1
|
||||
if ka[1] != kb[1]:
|
||||
return 1 if ka[1] > kb[1] else -1
|
||||
return 0
|
||||
|
||||
# ---------- Gitea helpers ----------
|
||||
|
||||
def _parse_repo_url(repo_url: str) -> tuple[str | None, str | None, str | None]:
|
||||
"""
|
||||
From https://host/owner/repo(.git) -> (api_base, owner, repo)
|
||||
api_base = https://host/api/v1
|
||||
"""
|
||||
try:
|
||||
pr = urlparse(repo_url.strip().rstrip('/'))
|
||||
parts = [p for p in pr.path.split('/') if p]
|
||||
if len(parts) >= 2:
|
||||
owner, repo = parts[0], parts[1]
|
||||
if repo.endswith('.git'):
|
||||
repo = repo[:-4]
|
||||
api_base = f"{pr.scheme}://{pr.netloc}/api/v1"
|
||||
return api_base, owner, repo
|
||||
except Exception:
|
||||
pass
|
||||
return None, None, None
|
||||
|
||||
def _auth_headers_from_cfg(r):
|
||||
"""
|
||||
Build Authorization header using repo auth tokens.
|
||||
- Preferred: cfg('repo_ahtoken') (comes from settings.json or env REPO_AHTOKEN)
|
||||
- Fallbacks: GITEA_TOKEN / GITEA_USER envs (non-SHAI)
|
||||
"""
|
||||
ahtoken = r.get('repo_ahtoken', '').strip() # REPO_AHTOKEN via settings/env
|
||||
if ahtoken:
|
||||
if not ahtoken.lower().startswith('token '):
|
||||
ahtoken = f"token {ahtoken}"
|
||||
return {"Authorization": ahtoken}
|
||||
|
||||
tok = os.getenv("GITEA_TOKEN", "").strip()
|
||||
usr = os.getenv("GITEA_USER", "").strip()
|
||||
if tok and usr:
|
||||
import base64
|
||||
b64 = base64.b64encode(f"{usr}:{tok}".encode()).decode()
|
||||
return {"Authorization": f"Basic {b64}"}
|
||||
if tok:
|
||||
return {"Authorization": f"token {tok}"}
|
||||
return {}
|
||||
|
||||
async def _http_json(url: str, headers: dict, timeout_sec: int = 10):
|
||||
import aiohttp
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_sec)
|
||||
async with aiohttp.ClientSession(timeout=timeout, headers=headers or {}) as sess:
|
||||
async with sess.get(url) as resp:
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise RuntimeError(f"Gitea GET {url} -> {resp.status} ({ctype}): {text[:200]}")
|
||||
if "application/json" not in ctype:
|
||||
text = await resp.text()
|
||||
raise RuntimeError(f"Gitea GET {url} non-JSON {ctype}: {text[:200]}")
|
||||
return await resp.json()
|
||||
|
||||
async def _fetch_latest_commit(api_base: str, owner: str, repo: str, branch: str | None,
|
||||
headers: dict) -> tuple[str | None, str | None, str | None]:
|
||||
"""
|
||||
Returns (sha, subject, body) for latest commit using list-commits:
|
||||
/api/v1/repos/{owner}/{repo}/commits?sha=main&stat=false&verification=false&files=false&limit=1
|
||||
If branch is falsy, omit 'sha' to use server default.
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
params = {
|
||||
"stat": "false",
|
||||
"verification": "false",
|
||||
"files": "false",
|
||||
"limit": "1",
|
||||
}
|
||||
if branch:
|
||||
params["sha"] = branch
|
||||
url = f"{api_base}/repos/{owner}/{repo}/commits?{urlencode(params)}"
|
||||
|
||||
data = await _http_json(url, headers)
|
||||
if not isinstance(data, list) or not data:
|
||||
raise RuntimeError("Commits list empty or invalid")
|
||||
|
||||
latest = data[0]
|
||||
sha = latest.get("sha") or latest.get("id")
|
||||
message = ""
|
||||
commit_obj = latest.get("commit") or {}
|
||||
if isinstance(commit_obj, dict):
|
||||
message = commit_obj.get("message") or ""
|
||||
|
||||
subject, body = _split_subject_body(message or "")
|
||||
return sha, (subject or ""), (body or "")
|
||||
|
||||
# ---------- Boot reason inference ----------
|
||||
|
||||
def _is_near_scheduled(now_utc: datetime, hhmm_utc: str | None, window_min: int = 5) -> bool:
|
||||
if not hhmm_utc:
|
||||
return False
|
||||
try:
|
||||
hh, mm = [int(x) for x in hhmm_utc.strip().split(':', 1)]
|
||||
except Exception:
|
||||
return False
|
||||
sched = now_utc.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
||||
return abs((now_utc - sched).total_seconds()) <= window_min * 60
|
||||
|
||||
def _format_status_line(kind: str, old_ver: str | None, new_ver: str | None) -> str:
|
||||
if kind == "updated":
|
||||
return f"✅ Updated from **{old_ver or 'unknown'}** → **{new_ver or 'unknown'}**"
|
||||
if kind == "scheduled":
|
||||
return "🕒 Scheduled restart executed"
|
||||
if kind == "manual":
|
||||
return "🟢 Manual restart detected"
|
||||
if kind == "rollback":
|
||||
return f"⚠️ Version rollback detected: **{old_ver or 'unknown'}** → **{new_ver or 'unknown'}**"
|
||||
return "🟢 Bot started"
|
||||
|
||||
# ---------- Main entry ----------
|
||||
|
||||
async def post_boot_notice(bot):
|
||||
"""
|
||||
Always posts a startup status to the modlog channel.
|
||||
- If version changed (update or rollback): post status + full commit message.
|
||||
- If NO version change (manual/scheduled): post status ONLY, but append the running version to that status.
|
||||
"""
|
||||
try:
|
||||
await bot.wait_until_ready()
|
||||
except Exception as e:
|
||||
print(f"[boot_notice] wait_until_ready failed: {e}")
|
||||
|
||||
for guild in bot.guilds:
|
||||
print(f' - {guild.name} (id: {guild.id})')
|
||||
|
||||
r = cfg(bot)
|
||||
modlog_channel_id = r.int('modlog_channel_id', 0)
|
||||
if not modlog_channel_id:
|
||||
print("[boot_notice] modlog_channel_id not configured; skipping.")
|
||||
return
|
||||
|
||||
ch = bot.get_channel(modlog_channel_id)
|
||||
if not ch:
|
||||
for g in bot.guilds:
|
||||
ch = g.get_channel(modlog_channel_id)
|
||||
if ch:
|
||||
break
|
||||
if not ch:
|
||||
print(f"[boot_notice] channel id {modlog_channel_id} not found; skipping.")
|
||||
return
|
||||
|
||||
repo_url = r.get('repo_url', '')
|
||||
branch = r.get('repo_branch', 'main') or None
|
||||
check_time_utc = r.get('check_time_utc', '')
|
||||
headers = _auth_headers_from_cfg(r)
|
||||
|
||||
api_base = owner = repo = None
|
||||
if repo_url:
|
||||
api_base, owner, repo = _parse_repo_url(repo_url)
|
||||
if not all([api_base, owner, repo]):
|
||||
print(f"[boot_notice] failed to parse repo_url={repo_url!r}")
|
||||
else:
|
||||
print("[boot_notice] repo_url missing; commit lookup skipped.")
|
||||
|
||||
# State
|
||||
dm = getattr(bot, "data_manager", None)
|
||||
if not dm:
|
||||
print("[boot_notice] data_manager missing on bot; cannot persist state.")
|
||||
return
|
||||
prev = (dm.get('boot_state') or [{}])[-1] if dm.get('boot_state') else {}
|
||||
prev_sha = prev.get('last_sha') or None
|
||||
prev_ver = prev.get('last_version') or None
|
||||
|
||||
# Fetch latest commit
|
||||
sha = subject = body = None
|
||||
if api_base and owner and repo:
|
||||
try:
|
||||
sha, subject, body = await _fetch_latest_commit(api_base, owner, repo, branch, headers)
|
||||
except Exception as e:
|
||||
print(f"[boot_notice] fetch latest commit failed: {e}")
|
||||
else:
|
||||
print("[boot_notice] repo parsing failed; commit lookup skipped.")
|
||||
|
||||
curr_ver = _extract_version(subject) if subject else None
|
||||
|
||||
# Decide reason
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if prev_ver and curr_ver:
|
||||
cmpv = _cmp_versions(prev_ver, curr_ver)
|
||||
if cmpv < 0:
|
||||
reason, ping_owner = "updated", False
|
||||
elif cmpv > 0:
|
||||
reason, ping_owner = "rollback", True
|
||||
else:
|
||||
reason, ping_owner = ("scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"), False
|
||||
else:
|
||||
if prev_sha and sha and prev_sha != sha:
|
||||
reason, ping_owner = "updated", False
|
||||
else:
|
||||
reason, ping_owner = ("scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"), False
|
||||
|
||||
# Build + post status line
|
||||
status_line = _format_status_line(reason, prev_ver, curr_ver)
|
||||
|
||||
# If no version change (manual/scheduled), append the running version to the status line,
|
||||
# and DO NOT post the commit message separately.
|
||||
append_version_only = reason in ("manual", "scheduled")
|
||||
if append_version_only and curr_ver:
|
||||
status_line = f"{status_line} — running **{curr_ver}**"
|
||||
|
||||
try:
|
||||
allowed = discord.AllowedMentions(
|
||||
everyone=False,
|
||||
users=True if (ping_owner and ch.guild and ch.guild.owner_id) else False,
|
||||
roles=False,
|
||||
replied_user=False
|
||||
)
|
||||
if ping_owner and ch.guild and ch.guild.owner_id:
|
||||
status_line = f"{status_line}\n<@{ch.guild.owner_id}>"
|
||||
await ch.send(status_line, allowed_mentions=allowed)
|
||||
except Exception as e:
|
||||
print(f"[boot_notice] failed to send status line: {e}")
|
||||
return
|
||||
|
||||
# Only post commit message if version CHANGED (updated or rollback)
|
||||
if not append_version_only:
|
||||
try:
|
||||
title = (curr_ver or subject or "Latest commit").strip()
|
||||
if title or body:
|
||||
commit_msg = f"**{title}**\n{body}" if body else f"**{title}**"
|
||||
await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none())
|
||||
except Exception as e:
|
||||
print(f"[boot_notice] failed to send commit message: {e}")
|
||||
|
||||
# Persist state
|
||||
try:
|
||||
dm.add('boot_state', {
|
||||
'last_sha': sha,
|
||||
'last_version': curr_ver,
|
||||
'last_subject': subject,
|
||||
'last_boot_ts': time.time(),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[boot_notice] failed to persist boot_state: {e}")
|
495
modules/common/settings.py
Normal file
@ -0,0 +1,495 @@
|
||||
# modules/common/settings.py
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any, Dict, Iterable, Optional, List
|
||||
|
||||
# =========================
|
||||
# Public API Exceptions
|
||||
# =========================
|
||||
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# =========================
|
||||
# Helpers
|
||||
# =========================
|
||||
|
||||
def _clean(s: Optional[str]) -> str:
|
||||
s = (s or "").strip()
|
||||
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
|
||||
def _is_intish(x: Any) -> bool:
|
||||
try:
|
||||
int(str(x).strip())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _to_bool(x: Any) -> bool:
|
||||
s = str(x).strip().lower()
|
||||
if s in ("1", "true", "yes", "on", "y", "t"):
|
||||
return True
|
||||
if s in ("0", "false", "no", "off", "n", "f"):
|
||||
return False
|
||||
raise ValidationError(f"Expected a boolean, got {x!r}")
|
||||
|
||||
def _to_int(x: Any) -> int:
|
||||
if _is_intish(x):
|
||||
return int(str(x).strip())
|
||||
raise ValidationError(f"Expected an integer, got {x!r}")
|
||||
|
||||
def _to_float(x: Any) -> float:
|
||||
try:
|
||||
return float(str(x).strip())
|
||||
except Exception:
|
||||
raise ValidationError(f"Expected a float, got {x!r}")
|
||||
|
||||
def _to_str(x: Any) -> str:
|
||||
return str(x)
|
||||
|
||||
def _to_list_int(x: Any) -> List[int]:
|
||||
if isinstance(x, list):
|
||||
out = []
|
||||
for v in x:
|
||||
if not _is_intish(v):
|
||||
raise ValidationError(f"List must contain integers; got {v!r}")
|
||||
out.append(int(v))
|
||||
return out
|
||||
if isinstance(x, str):
|
||||
toks = [t.strip() for t in x.split(",") if t.strip()]
|
||||
try:
|
||||
return [int(t) for t in toks]
|
||||
except Exception:
|
||||
raise ValidationError(f"Could not parse list of integers from {x!r}")
|
||||
raise ValidationError(f"Expected a list of integers, got {type(x).__name__}")
|
||||
|
||||
# ---- URL / Time / Date validators ----
|
||||
|
||||
def _to_url(x: Any) -> str:
|
||||
s = str(x).strip()
|
||||
if not s:
|
||||
raise ValidationError("URL cannot be empty")
|
||||
p = urlparse(s)
|
||||
if p.scheme not in ("http", "https"):
|
||||
raise ValidationError("URL must start with http:// or https://")
|
||||
if not p.netloc:
|
||||
raise ValidationError("URL missing host")
|
||||
if not re.match(r"^([A-Za-z0-9\-.]+|\d{1,3}(?:\.\d{1,3}){3}|localhost)(:\d+)?$", p.netloc):
|
||||
raise ValidationError("URL host looks invalid")
|
||||
return s
|
||||
|
||||
_TIME_24H_RE = re.compile(r"^(?P<h>[01]?\d|2[0-3]):(?P<m>[0-5]\d)(?::(?P<s>[0-5]\d))?$")
|
||||
|
||||
def _to_time_24h(x: Any) -> str:
|
||||
s = str(x).strip()
|
||||
m = _TIME_24H_RE.match(s)
|
||||
if not m:
|
||||
raise ValidationError("Time must be HH:MM or HH:MM:SS (24-hour)")
|
||||
hh = int(m.group("h"))
|
||||
mm = int(m.group("m"))
|
||||
# canonical store as HH:MM
|
||||
return f"{hh:02d}:{mm:02d}"
|
||||
|
||||
def _expand_two_digit_year(two_digit: int, pivot_year: int) -> int:
|
||||
# Map YY -> same century as pivot_year
|
||||
base = pivot_year - (pivot_year % 100)
|
||||
return base + two_digit
|
||||
|
||||
def _to_date_ymd(x: Any, *, pivot_year: int | None = None) -> str:
|
||||
s = str(x).strip()
|
||||
if not s:
|
||||
raise ValidationError("Date cannot be empty")
|
||||
m = re.match(r"^(?P<y>\d{2}|\d{4})-(?P<m>\d{1,2})-(?P<d>\d{1,2})$", s)
|
||||
if not m:
|
||||
raise ValidationError("Expected date format YYYY-MM-DD or YY-MM-DD")
|
||||
y = m.group("y")
|
||||
month = int(m.group("m"))
|
||||
day = int(m.group("d"))
|
||||
if len(y) == 2:
|
||||
yy = int(y)
|
||||
if pivot_year is not None:
|
||||
year = _expand_two_digit_year(yy, pivot_year)
|
||||
dt = datetime(year, month, day)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
else:
|
||||
dt = datetime.strptime(s, "%y-%m-%d")
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
year = int(y)
|
||||
dt = datetime(year, month, day)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
def _to_date_dmy(x: Any, *, pivot_year: int | None = None) -> str:
|
||||
s = str(x).strip()
|
||||
if not s:
|
||||
raise ValidationError("Date cannot be empty")
|
||||
m = re.match(r"^(?P<d>\d{1,2})-(?P<m>\d{1,2})-(?P<y>\d{2}|\d{4})$", s)
|
||||
if not m:
|
||||
raise ValidationError("Expected date format DD-MM-YYYY or DD-MM-YY")
|
||||
day = int(m.group("d"))
|
||||
month = int(m.group("m"))
|
||||
y = m.group("y")
|
||||
if len(y) == 2:
|
||||
yy = int(y)
|
||||
if pivot_year is not None:
|
||||
year = _expand_two_digit_year(yy, pivot_year)
|
||||
dt = datetime(year, month, day)
|
||||
return dt.strftime("%d-%m-%Y")
|
||||
else:
|
||||
dt = datetime.strptime(s, "%d-%m-%y")
|
||||
return dt.strftime("%d-%m-%Y")
|
||||
year = int(y)
|
||||
dt = datetime(year, month, day)
|
||||
return dt.strftime("%d-%m-%Y")
|
||||
|
||||
|
||||
# =========================
|
||||
# Schema (non-sensitive, front-end editable)
|
||||
# =========================
|
||||
|
||||
SETTINGS_SCHEMA: Dict[str, Dict[str, Any]] = {
|
||||
# Channels (IDs)
|
||||
"mod_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Moderation command channel."},
|
||||
"modlog_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "ModLog channel."},
|
||||
"pirates_list_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Pirates list channel."},
|
||||
"dd_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Deep Desert updates channel."},
|
||||
"report_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Reports/approvals channel."},
|
||||
"userslist_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Users list channel."},
|
||||
"trigger_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Trigger channel for Auto VC."},
|
||||
"crew_roles_message_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Message ID for crew reaction roles hub."},
|
||||
|
||||
# Roles (IDs)
|
||||
"rules_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Rules-agreed role ID."},
|
||||
"moderator_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Moderator role ID."},
|
||||
"intel_mod_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Intel mod role ID."},
|
||||
"full_access_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Full Access role ID."},
|
||||
"field_mod_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Field mod role ID."},
|
||||
"engagement_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Engagement role ID."},
|
||||
"admin_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Admin role ID."},
|
||||
# Role IDs for crew groups
|
||||
"role_harvest_crew_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Role: Harvest Crew"},
|
||||
"role_escort_crew_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Role: Escort Crew"},
|
||||
"role_fedaykin_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Role: Fedaykin"},
|
||||
|
||||
# Message IDs
|
||||
"rules_message_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Rules message ID."},
|
||||
"engagement_message_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Engagement message ID."},
|
||||
"nickname_message_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Nickname message ID."},
|
||||
|
||||
# Emojis (IDs)
|
||||
"emoji_carrier_crawler_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Custom emoji: carrier/crawler."},
|
||||
"emoji_melange_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Custom emoji: melange."},
|
||||
"emoji_sand_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Custom emoji: sand."},
|
||||
"emoji_harvester_crew": {"type": "int", "default": 0, "nonzero": True, "desc": "Custom emoji: harvester crew"},
|
||||
"emoji_escort_crew": {"type": "int", "default": 0, "nonzero": True, "desc": "Custom emoji: escort crew"},
|
||||
"emoji_fedaykin": {"type": "int", "default": 0, "nonzero": True, "desc": "Custom emoji: fedaykin - kill squad"},
|
||||
|
||||
# Auto-VC
|
||||
"auto_vc_category_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Category to host Auto-VCs."},
|
||||
"auto_vc_cleanup_delay": {"type": "int", "default": 30, "nonzero": True, "desc": "Seconds before empty Auto-VCs are cleaned up."},
|
||||
"vc_name_prefix": {"type": "str", "default": "DD Crew", "desc": "Auto-VC name prefix."},
|
||||
|
||||
# Pirates / threat model
|
||||
"threat_group_threshold": {"type": "int", "default": 3, "desc": "Threshold for group classification."},
|
||||
"threat_min_samples_for_stats": {"type": "int", "default": 3, "desc": "Min samples for stats."},
|
||||
"threat_w_destruction": {"type": "float", "default": 0.40, "desc": "Weight: destruction."},
|
||||
"threat_w_group": {"type": "float", "default": 0.20, "desc": "Weight: group."},
|
||||
"threat_w_kill": {"type": "float", "default": 0.30, "desc": "Weight: kill."},
|
||||
"threat_w_skill": {"type": "float", "default": 0.10, "desc": "Weight: skill."},
|
||||
|
||||
# SpicePay
|
||||
"spicepay_base_weight": {"type": "float", "default": 25.0, "desc": "Base weight."},
|
||||
"spicepay_carrier_bonus": {"type": "float", "default": 12.5, "desc": "Carrier bonus."},
|
||||
"spicepay_crawler_bonus": {"type": "float", "default": 12.5, "desc": "Crawler bonus."},
|
||||
"spicepay_lsr_cut_percent": {"type": "float", "default": 10.0, "desc": "SR cut percent."},
|
||||
|
||||
# Jobs / loops
|
||||
"user_cards_cron_enabled": {"type": "bool", "default": True, "desc": "Enable user-cards cron."},
|
||||
"nick_nudge_loop_enabled": {"type": "bool", "default": False, "desc": "Enable nick-nudge loop."},
|
||||
|
||||
# Deep Desert fetcher
|
||||
"dd_fetcher": {"type": "str", "default": "playwright", "allowed": ["playwright","requests"], "desc": "Fetcher backend."},
|
||||
"dd_pw_timeout_ms": {"type": "int", "default": 60000, "desc": "Playwright timeout (ms)."},
|
||||
"dd_pw_wait_ms": {"type": "int", "default": 0, "desc": "Extra wait after navigation (ms)."},
|
||||
|
||||
# Repo (non-secret)
|
||||
"repo_url": {"type": "url",
|
||||
"default": "https://git.rolfsvaag.no/frarol96/shaiwatcher",
|
||||
"desc": "Repository URL."},
|
||||
"repo_branch": {"type": "str", "default": "main", "desc": "Repository branch."},
|
||||
"repo_rss": {"type": "url",
|
||||
"default": "https://git.rolfsvaag.no/frarol96/shaiwatcher.rss",
|
||||
"desc": "Repository RSS feed."},
|
||||
|
||||
# Admin allow-list for /shaiadmin (besides owner)
|
||||
"admin_user_ids": {"type": "list[int]", "default": [], "desc": "User IDs allowed to use /shaiadmin."},
|
||||
"admin_role_ids": {"type": "list[int]", "default": [], "desc": "Role IDs allowed to use /shaiadmin."},
|
||||
|
||||
# Misc
|
||||
"check_time_utc": {"type": "time_24h", "default": "03:00", "desc": "Daily check time (UTC HH:MM)"},
|
||||
"ignore_test_level": {"type": "int", "default": 0, "desc": "Test-level ignore flag."},
|
||||
"lang": {"type": "str", "default": "C.UTF-8", "desc": "Locale (if referenced)."},
|
||||
# Examples of date keys you may enable later:
|
||||
# "feature_window_start": {"type": "date_ymd", "default": "", "allow_empty": True, "pivot_year": 2000, "desc": "Start date (YYYY-MM-DD or YY-MM-DD)."},
|
||||
# "event_date_dmy": {"type": "date_dmy", "default": "", "allow_empty": True, "pivot_year": 2000, "desc": "Event date (DD-MM-YYYY or DD-MM-YY)."},
|
||||
}
|
||||
|
||||
# =========================
|
||||
# Env — ONLY the allowed set (plus legacy HOME_GUILD_ID alias)
|
||||
# =========================
|
||||
|
||||
def _allowed_env_map() -> Dict[str, str]:
|
||||
env: Dict[str, str] = {}
|
||||
if os.getenv("DISCORD_TOKEN"):
|
||||
env["discord_token"] = _clean(os.getenv("DISCORD_TOKEN"))
|
||||
data_file = os.getenv("DATA_FILE")
|
||||
if data_file:
|
||||
env["data_file"] = _clean(data_file)
|
||||
if os.getenv("DOCS_HOST_IP"):
|
||||
env["docs_host_ip"] = _clean(os.getenv("DOCS_HOST_IP"))
|
||||
if os.getenv("DOCS_HOST_PORT"):
|
||||
env["docs_host_port"] = _clean(os.getenv("DOCS_HOST_PORT"))
|
||||
if os.getenv("HOME_GUILD_ID"):
|
||||
env["home_guild_id"] = _clean(os.getenv("HOME_GUILD_ID"))
|
||||
if os.getenv("REPO_AHTOKEN"):
|
||||
env["repo_ahtoken"] = _clean(os.getenv("REPO_AHTOKEN"))
|
||||
return env
|
||||
|
||||
# =========================
|
||||
# On-disk store + globals
|
||||
# =========================
|
||||
|
||||
_SETTINGS_LOCK = threading.Lock()
|
||||
_FILE_MAP: Dict[str, Any] = {}
|
||||
_ENV_MAP: Dict[str, str] = {}
|
||||
_SETTINGS_FILE: Optional[str] = None
|
||||
|
||||
def settings_path() -> str:
|
||||
"""Place settings.json next to DATA_FILE if available; otherwise default to ./data/settings.json."""
|
||||
data_file = os.getenv("DATA_FILE")
|
||||
if data_file:
|
||||
base = os.path.dirname(data_file) or "."
|
||||
return os.path.join(base, "settings.json")
|
||||
return "./data/settings.json"
|
||||
|
||||
def _ensure_loaded():
|
||||
global _SETTINGS_FILE, _FILE_MAP, _ENV_MAP
|
||||
with _SETTINGS_LOCK:
|
||||
if _SETTINGS_FILE is not None:
|
||||
return
|
||||
_SETTINGS_FILE = settings_path()
|
||||
_ENV_MAP = _allowed_env_map()
|
||||
|
||||
if os.path.exists(_SETTINGS_FILE):
|
||||
try:
|
||||
with open(_SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||||
_FILE_MAP = json.load(f) or {}
|
||||
except Exception:
|
||||
_FILE_MAP = {}
|
||||
else:
|
||||
_FILE_MAP = {}
|
||||
_save_locked()
|
||||
|
||||
changed = False
|
||||
for key, meta in SETTINGS_SCHEMA.items():
|
||||
if key not in _FILE_MAP:
|
||||
_FILE_MAP[key] = meta.get("default")
|
||||
changed = True
|
||||
if changed:
|
||||
_save_locked()
|
||||
|
||||
def _save_locked():
|
||||
global _SETTINGS_FILE, _FILE_MAP
|
||||
path = _SETTINGS_FILE or settings_path()
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(_FILE_MAP, f, indent=2, ensure_ascii=False)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
shutil.copy2(path, path + ".bak")
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
|
||||
def settings_get_all() -> Dict[str, Any]:
|
||||
_ensure_loaded()
|
||||
with _SETTINGS_LOCK:
|
||||
return dict(_FILE_MAP)
|
||||
|
||||
def _cast_value(name: str, raw: Any, *, enforce_nonzero: bool = True) -> Any:
|
||||
meta = SETTINGS_SCHEMA.get(name)
|
||||
if not meta:
|
||||
raise ValidationError(f"Unknown setting: {name}")
|
||||
|
||||
allow_empty = bool(meta.get("allow_empty", False))
|
||||
t = meta.get("type")
|
||||
|
||||
if t in ("str", "url", "time_24h", "date_ymd", "date_dmy"):
|
||||
s = str(raw).strip()
|
||||
if s == "" and allow_empty:
|
||||
val = ""
|
||||
else:
|
||||
if t == "str":
|
||||
val = _to_str(raw)
|
||||
elif t == "url":
|
||||
val = _to_url(raw)
|
||||
elif t == "time_24h":
|
||||
val = _to_time_24h(raw)
|
||||
elif t == "date_ymd":
|
||||
val = _to_date_ymd(raw, pivot_year=meta.get("pivot_year"))
|
||||
elif t == "date_dmy":
|
||||
val = _to_date_dmy(raw, pivot_year=meta.get("pivot_year"))
|
||||
elif t == "bool":
|
||||
val = _to_bool(raw)
|
||||
elif t == "int":
|
||||
val = _to_int(raw)
|
||||
elif t == "float":
|
||||
val = _to_float(raw)
|
||||
elif t == "list[int]":
|
||||
val = _to_list_int(raw)
|
||||
else:
|
||||
raise ValidationError(f"Unsupported type for {name}: {t}")
|
||||
|
||||
# enum constraint (always enforced)
|
||||
if "allowed" in meta:
|
||||
allowed = meta["allowed"]
|
||||
if val not in allowed:
|
||||
raise ValidationError(f"`{name}` must be one of {allowed}, got {val!r}")
|
||||
|
||||
# nonzero constraint (skippable for bulk uploads)
|
||||
if enforce_nonzero and meta.get("nonzero") and isinstance(val, int) and val == 0:
|
||||
raise ValidationError(f"`{name}` must be a non-zero integer.")
|
||||
|
||||
return val
|
||||
|
||||
def settings_set(name: str, raw_value: Any) -> bool:
|
||||
_ensure_loaded()
|
||||
with _SETTINGS_LOCK:
|
||||
name = name.lower().strip()
|
||||
if name not in SETTINGS_SCHEMA:
|
||||
raise ValidationError(f"Unknown setting: {name}")
|
||||
new_val = _cast_value(name, raw_value)
|
||||
old_val = _FILE_MAP.get(name, SETTINGS_SCHEMA[name].get("default"))
|
||||
if old_val == new_val:
|
||||
return False
|
||||
_FILE_MAP[name] = new_val
|
||||
_save_locked()
|
||||
return True
|
||||
|
||||
def settings_reset(name: str) -> None:
|
||||
_ensure_loaded()
|
||||
with _SETTINGS_LOCK:
|
||||
name = name.lower().strip()
|
||||
if name not in SETTINGS_SCHEMA:
|
||||
raise ValidationError(f"Unknown setting: {name}")
|
||||
_FILE_MAP[name] = SETTINGS_SCHEMA[name].get("default")
|
||||
_save_locked()
|
||||
|
||||
def settings_import_bulk(obj: Dict[str, Any]) -> List[str]:
|
||||
_ensure_loaded()
|
||||
if not isinstance(obj, dict):
|
||||
raise ValidationError("Uploaded JSON must be an object/dict at the top level.")
|
||||
new_map: Dict[str, Any] = dict(_FILE_MAP)
|
||||
changed: List[str] = []
|
||||
for k, v in obj.items():
|
||||
if k not in SETTINGS_SCHEMA:
|
||||
raise ValidationError(f"Unknown setting in upload: {k}")
|
||||
# Allow 0 for keys marked nonzero during bulk import (treating as 'unset' sentinel)
|
||||
new_val = _cast_value(k, v, enforce_nonzero=False)
|
||||
if new_map.get(k) != new_val:
|
||||
new_map[k] = new_val
|
||||
changed.append(k)
|
||||
with _SETTINGS_LOCK:
|
||||
if changed:
|
||||
_FILE_MAP.update({k: new_map[k] for k in changed})
|
||||
_save_locked()
|
||||
return changed
|
||||
|
||||
# =========================
|
||||
# Unified read view (keeps cfg(bot) contract)
|
||||
# =========================
|
||||
|
||||
class ConfigView:
|
||||
"""
|
||||
Reads:
|
||||
- Schema-managed keys from settings.json
|
||||
- Env: discord_token, data_file, docs_host_ip, docs_host_port, home_guild_id
|
||||
- Fallback to bot.config['DEFAULT'] for anything else (legacy)
|
||||
Helpers: get/int/bool/float/list, to_dict()
|
||||
"""
|
||||
def __init__(self, bot=None):
|
||||
_ensure_loaded()
|
||||
self._env_map = dict(_ENV_MAP)
|
||||
try:
|
||||
self._default = (getattr(bot, "config", {}) or {}).get("DEFAULT", {}) or {}
|
||||
except Exception:
|
||||
self._default = {}
|
||||
|
||||
def _effective_map(self) -> Dict[str, str]:
|
||||
merged: Dict[str, str] = {}
|
||||
# defaults first
|
||||
for k in getattr(self._default, "keys", lambda: [])():
|
||||
merged[k] = _clean(str(self._default.get(k, "")))
|
||||
# env overlay
|
||||
for k, v in self._env_map.items():
|
||||
merged[k] = _clean(v)
|
||||
# schema values overlay defaults
|
||||
for k, meta in SETTINGS_SCHEMA.items():
|
||||
v = _FILE_MAP.get(k, meta.get("default"))
|
||||
if isinstance(v, (list, dict)):
|
||||
merged[k] = json.dumps(v, ensure_ascii=False)
|
||||
else:
|
||||
merged[k] = _clean(str(v))
|
||||
return merged
|
||||
|
||||
def get(self, key: str, default: str = "") -> str:
|
||||
m = self._effective_map()
|
||||
v = _clean(m.get(key.lower(), ""))
|
||||
return v if v != "" else default
|
||||
|
||||
def int(self, key: str, default: int = 0) -> int:
|
||||
s = self.get(key, "")
|
||||
try:
|
||||
return int(s)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def float(self, key: str, default: float = 0.0) -> float:
|
||||
s = self.get(key, "")
|
||||
try:
|
||||
return float(s)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def bool(self, key: str, default: bool = False) -> bool:
|
||||
s = self.get(key, "")
|
||||
if s == "":
|
||||
return default
|
||||
s = s.lower()
|
||||
if s in ("1", "true", "yes", "on", "y", "t"):
|
||||
return True
|
||||
if s in ("0", "false", "no", "off", "n", "f"):
|
||||
return False
|
||||
return default
|
||||
|
||||
def list(self, key: str, default: Optional[Iterable[str]] = None, sep: str = ",") -> Iterable[str]:
|
||||
s = self.get(key, "")
|
||||
if s == "":
|
||||
return list(default or [])
|
||||
parts = [p.strip() for p in s.split(sep)]
|
||||
return [p for p in parts if p]
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return dict(self._effective_map())
|
||||
|
||||
|
||||
def cfg(bot=None) -> ConfigView:
|
||||
return ConfigView(bot)
|
0
modules/data_admin/__init__.py
Normal file
237
modules/data_admin/data_admin.py
Normal file
@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
import asyncio
|
||||
from typing import Optional, Literal
|
||||
|
||||
import aiohttp
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MiB hard cap for safety
|
||||
OWNER_HINT = "This command is restricted to the **server owner** (or bot owner)."
|
||||
|
||||
def _now_stamp() -> str:
|
||||
return time.strftime("%Y%m%d-%H%M%S", time.gmtime())
|
||||
|
||||
class DataAdmin(commands.Cog):
|
||||
"""
|
||||
[ADMIN] Backup/restore the bot data file.
|
||||
Owner-only: guild owner or application (bot) owner.
|
||||
"""
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self._app_owner_id: Optional[int] = None
|
||||
|
||||
# --- permission helper ---
|
||||
async def _is_owner(self, interaction: discord.Interaction) -> bool:
|
||||
uid = interaction.user.id
|
||||
# cache application owner id
|
||||
if self._app_owner_id is None:
|
||||
try:
|
||||
info = await self.bot.application_info()
|
||||
if info and info.owner:
|
||||
self._app_owner_id = info.owner.id
|
||||
except Exception:
|
||||
self._app_owner_id = None
|
||||
|
||||
guild_owner_id = getattr(getattr(interaction, "guild", None), "owner_id", None)
|
||||
if guild_owner_id and uid == guild_owner_id:
|
||||
return True
|
||||
if self._app_owner_id and uid == self._app_owner_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
# --- helpers ---
|
||||
def _dm_path(self) -> str:
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
if not dm or not getattr(dm, "json_path", None):
|
||||
raise RuntimeError("DataManager/json_path unavailable")
|
||||
return dm.json_path
|
||||
|
||||
def _merge_with_defaults(self, incoming: dict) -> dict:
|
||||
"""
|
||||
Ensure required keys exist; preserve unknown keys.
|
||||
"""
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
if not dm:
|
||||
raise RuntimeError("DataManager unavailable")
|
||||
|
||||
# Create a minimal default schema by calling _default_payload if present,
|
||||
# otherwise fall back to a thin set.
|
||||
try:
|
||||
defaults = dm._default_payload() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
defaults = {
|
||||
"_counters": {},
|
||||
"_events_seen": {},
|
||||
"_counter_last_ts": {},
|
||||
}
|
||||
|
||||
merged = dict(incoming)
|
||||
for k, v in defaults.items():
|
||||
merged.setdefault(k, v if not isinstance(v, list) else list(v))
|
||||
return merged
|
||||
|
||||
async def _download_attachment_bytes(self, att: discord.Attachment) -> bytes:
|
||||
if att.size > MAX_UPLOAD_BYTES:
|
||||
raise ValueError(f"Attachment too large ({att.size} bytes)")
|
||||
return await att.read()
|
||||
|
||||
async def _download_url_bytes(self, url: str) -> bytes:
|
||||
timeout = aiohttp.ClientTimeout(total=25, sock_connect=10, sock_read=15)
|
||||
headers = {
|
||||
"User-Agent": "ShaiWatcher/backup-restore (+https://example.invalid)"
|
||||
}
|
||||
async with aiohttp.ClientSession(timeout=timeout) as sess:
|
||||
async with sess.get(url, headers=headers, allow_redirects=True) as resp:
|
||||
if resp.status >= 400:
|
||||
raise RuntimeError(f"HTTP {resp.status}")
|
||||
data = await resp.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise ValueError(f"Downloaded file too large ({len(data)} bytes)")
|
||||
return data
|
||||
|
||||
def _atomic_replace(self, new_payload: dict) -> None:
|
||||
"""
|
||||
Replace DataManager payload atomically, with a timestamped backup.
|
||||
"""
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
if not dm:
|
||||
raise RuntimeError("DataManager unavailable")
|
||||
|
||||
src_path = self._dm_path()
|
||||
bak_path = f"{src_path}.manual.{_now_stamp()}.bak"
|
||||
|
||||
with dm.lock:
|
||||
# backup current file if exists
|
||||
try:
|
||||
if os.path.exists(src_path):
|
||||
shutil.copy2(src_path, bak_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# write new file and update in-memory view
|
||||
dm._data = self._merge_with_defaults(new_payload) # type: ignore[attr-defined]
|
||||
dm._save(dm._data) # type: ignore[attr-defined]
|
||||
|
||||
# --- slash command ---
|
||||
@app_commands.command(
|
||||
name="data",
|
||||
description="[ADMIN] Download or upload the bot data file (owner-only)"
|
||||
)
|
||||
@app_commands.describe(
|
||||
action="Choose 'download' to get the current file, or 'upload' to restore from JSON",
|
||||
attachment="Optional JSON attachment (used for 'upload')",
|
||||
url="Optional direct URL to a JSON file (used for 'upload')"
|
||||
)
|
||||
async def data_cmd(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
action: Literal["download", "upload"],
|
||||
attachment: Optional[discord.Attachment] = None,
|
||||
url: Optional[str] = None,
|
||||
):
|
||||
# perms
|
||||
if not await self._is_owner(interaction):
|
||||
return await interaction.response.send_message(OWNER_HINT, ephemeral=True)
|
||||
|
||||
# ensure dm available
|
||||
try:
|
||||
dm_path = self._dm_path()
|
||||
except Exception as e:
|
||||
return await interaction.response.send_message(
|
||||
f"DataManager unavailable: {e}", ephemeral=True
|
||||
)
|
||||
|
||||
# dispatch
|
||||
if action == "download":
|
||||
await interaction.response.defer(ephemeral=True, thinking=False)
|
||||
try:
|
||||
# Read raw file bytes to guarantee exact copy
|
||||
with open(dm_path, "rb") as f:
|
||||
data = f.read()
|
||||
file = discord.File(io.BytesIO(data), filename="data.json")
|
||||
await interaction.followup.send(
|
||||
content="Here is the current data file.",
|
||||
file=file,
|
||||
ephemeral=True,
|
||||
)
|
||||
except Exception as e:
|
||||
await interaction.followup.send(
|
||||
f"Failed to read data file: {e}", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# upload
|
||||
# must provide exactly one source
|
||||
sources = [s for s in (attachment, url) if s]
|
||||
if len(sources) != 1:
|
||||
return await interaction.response.send_message(
|
||||
"For `upload`, provide **exactly one** of: `attachment` **or** `url`.",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
|
||||
try:
|
||||
if attachment:
|
||||
raw = await self._download_attachment_bytes(attachment)
|
||||
else:
|
||||
assert url is not None
|
||||
raw = await self._download_url_bytes(url)
|
||||
|
||||
# decode → JSON
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return await interaction.followup.send(
|
||||
"The file/URL is not valid UTF-8 text.", ephemeral=True
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
return await interaction.followup.send(
|
||||
f"Invalid JSON: {e}", ephemeral=True
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return await interaction.followup.send(
|
||||
"Top-level JSON must be an **object** (not an array/string).",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# final size sanity (after parse)
|
||||
encoded_size = len(json.dumps(payload, ensure_ascii=False).encode("utf-8"))
|
||||
if encoded_size > MAX_UPLOAD_BYTES:
|
||||
return await interaction.followup.send(
|
||||
f"Refusing to import unusually large JSON ({encoded_size} bytes).",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# write & backup
|
||||
self._atomic_replace(payload)
|
||||
|
||||
# tiny summary
|
||||
top_keys = sorted(list(payload.keys()))
|
||||
shown = ", ".join(top_keys[:12]) + ("…" if len(top_keys) > 12 else "")
|
||||
await interaction.followup.send(
|
||||
f"✅ Imported data and wrote a timestamped backup of the previous file.\n"
|
||||
f"Path: `{dm_path}`\n"
|
||||
f"Top-level keys ({len(top_keys)}): {shown}",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"Import failed: {e}", ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(DataAdmin(bot))
|
0
modules/dd/__init__.py
Normal file
781
modules/dd/dd_loot_table.py
Normal file
@ -0,0 +1,781 @@
|
||||
# modules/dd/dd_loot_table.py
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple, Literal
|
||||
|
||||
import aiohttp
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
|
||||
from modules.common.settings import cfg
|
||||
|
||||
DD_FALLBACK_CHANNEL = 1404764793377652807
|
||||
DD_URL = "https://dune.gaming.tools/deep-desert"
|
||||
OWNER_ID = 203190147582394369 # for error notices
|
||||
|
||||
def _log(*a): print("[DD]", *a)
|
||||
def _utcnow() -> datetime: return datetime.now(timezone.utc)
|
||||
|
||||
def _this_week_anchor(now: Optional[datetime] = None) -> datetime:
|
||||
if now is None: now = _utcnow()
|
||||
target_wd = 1 # Tue
|
||||
cur_wd = now.weekday()
|
||||
delta_days = (cur_wd - target_wd) % 7
|
||||
anchor_date = (now - timedelta(days=delta_days)).date()
|
||||
anchor_dt = datetime(anchor_date.year, anchor_date.month, anchor_date.day, 3, 0, 0, tzinfo=timezone.utc)
|
||||
if now < anchor_dt: anchor_dt -= timedelta(days=7)
|
||||
return anchor_dt
|
||||
|
||||
def _next_week_anchor(after: Optional[datetime] = None) -> datetime:
|
||||
return _this_week_anchor(after) + timedelta(days=7)
|
||||
|
||||
def _backoff_delay_secs(waiting_since: float, now_ts: float) -> int:
|
||||
waited = max(0.0, now_ts - waiting_since)
|
||||
if waited < 3600: return 5 * 60
|
||||
if waited < 3 * 3600: return 15 * 60
|
||||
if waited < 6 * 3600: return 30 * 60
|
||||
if waited < 24 * 3600: return 60 * 60
|
||||
return 3 * 60 * 60
|
||||
|
||||
@dataclass
|
||||
class DDState:
|
||||
channel_id: int
|
||||
message_id: Optional[int]
|
||||
disabled: bool
|
||||
|
||||
# hashes
|
||||
last_hash: str # current cycle
|
||||
prev_hash: str # previous cycle
|
||||
last_post_hash: str # hash of the message content currently posted
|
||||
|
||||
week_anchor_ts: int
|
||||
last_success_ts: int
|
||||
waiting_since_ts: int
|
||||
last_attempt_ts: int
|
||||
|
||||
@classmethod
|
||||
def from_dm(cls, dm) -> "DDState":
|
||||
rows = dm.get("dd_state")
|
||||
row = rows[0] if rows else {}
|
||||
env_raw = os.getenv("SHAI_DD_CHANNEL_ID", "").strip().strip('"').strip("'")
|
||||
env_cid = int(env_raw) if env_raw.isdigit() else 0
|
||||
try:
|
||||
stored_cid = int(row.get("channel_id") or 0)
|
||||
except Exception:
|
||||
stored_cid = 0
|
||||
chosen_cid = env_cid or stored_cid or DD_FALLBACK_CHANNEL
|
||||
return cls(
|
||||
channel_id=chosen_cid,
|
||||
message_id=row.get("message_id"),
|
||||
disabled=bool(row.get("disabled", False)),
|
||||
last_hash=str(row.get("last_hash", "")),
|
||||
prev_hash=str(row.get("prev_hash", "")),
|
||||
last_post_hash=str(row.get("last_post_hash", "")),
|
||||
week_anchor_ts=int(row.get("week_anchor_ts", 0)),
|
||||
last_success_ts=int(row.get("last_success_ts", 0)),
|
||||
waiting_since_ts=int(row.get("waiting_since_ts", 0)),
|
||||
last_attempt_ts=int(row.get("last_attempt_ts", 0)),
|
||||
)
|
||||
|
||||
def to_row(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"channel_id": self.channel_id,
|
||||
"message_id": self.message_id,
|
||||
"disabled": self.disabled,
|
||||
"last_hash": self.last_hash,
|
||||
"prev_hash": self.prev_hash,
|
||||
"last_post_hash": self.last_post_hash,
|
||||
"week_anchor_ts": self.week_anchor_ts,
|
||||
"last_success_ts": self.last_success_ts,
|
||||
"waiting_since_ts": self.waiting_since_ts,
|
||||
"last_attempt_ts": self.last_attempt_ts,
|
||||
}
|
||||
|
||||
# ---------- parsing ----------
|
||||
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
DETAILS_BLOCK_RE = re.compile(r"<details[^>]*>.*?</details>", re.I | re.S)
|
||||
NAME_SPAN_RE = re.compile(r"<summary[^>]*>.*?<span[^>]*>(?P<name>[^<]+)</span>.*?</summary>", re.I | re.S)
|
||||
ROW_RE = re.compile(
|
||||
r'<div[^>]*class="[^"]*flex[^"]*items-center[^"]*gap-2[^"]*"[^>]*>\s*'
|
||||
r'<div[^>]*class="[^"]*w-8[^"]*text-center[^"]*"[^>]*>\s*(?P<grid>[A-Z]\d+)\s*</div>\s*'
|
||||
r'<div[^>]*>\s*(?P<loc>[^<]+?)\s*</div>.*?'
|
||||
r'<div[^>]*class="[^"]*ml-auto[^"]*"[^>]*>.*?'
|
||||
r'<div[^>]*class="[^"]*w-10[^"]*text-center[^"]*"[^>]*>\s*(?P<amt>[^<]+?)\s*</div>\s*'
|
||||
r'<div[^>]*>\s*(?P<chance>~?\d+%|\d+\.\d+%)\s*</div>.*?'
|
||||
r'</div>\s*</div>',
|
||||
re.I | re.S,
|
||||
)
|
||||
|
||||
def _parse_dd_html(html: str) -> List[Dict[str, str]]:
|
||||
results: List[Dict[str, str]] = []
|
||||
for dmatch in DETAILS_BLOCK_RE.finditer(html or ""):
|
||||
block = dmatch.group(0)
|
||||
nmatch = NAME_SPAN_RE.search(block)
|
||||
if not nmatch: continue
|
||||
name = " ".join(nmatch.group("name").split())
|
||||
for rmatch in ROW_RE.finditer(block):
|
||||
grid = " ".join(rmatch.group("grid").split())
|
||||
loc = " ".join(rmatch.group("loc").split())
|
||||
amt = " ".join(rmatch.group("amt").split())
|
||||
chance = " ".join(rmatch.group("chance").split())
|
||||
results.append({"name": name, "grid": grid, "loc": loc, "amount": amt, "chance": chance})
|
||||
return results
|
||||
|
||||
def _hash_text(s: str) -> str:
|
||||
return hashlib.sha1(s.encode("utf-8")).hexdigest()
|
||||
|
||||
def _hash_records(rows) -> str:
|
||||
rows = _sanitize_rows(rows)
|
||||
m = hashlib.sha256()
|
||||
for r in rows:
|
||||
m.update(f"{r['name']}|{r['grid']}|{r['loc']}|{r['amount']}|{r['chance']}\n".encode("utf-8"))
|
||||
return m.hexdigest()
|
||||
|
||||
# ---------- formatters ----------
|
||||
|
||||
def _as_str(v) -> str:
|
||||
"""Coerce any value (incl. lists/tuples) to a compact string."""
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, (list, tuple, set)):
|
||||
try:
|
||||
return ", ".join(map(str, v))
|
||||
except Exception:
|
||||
return str(v)
|
||||
return str(v)
|
||||
|
||||
def _sanitize_rows(rows):
|
||||
"""Return rows with all fields as trimmed strings; safe for hashing/formatting."""
|
||||
out = []
|
||||
for r in rows or []:
|
||||
out.append({
|
||||
"name": _as_str(r.get("name", "")).strip(),
|
||||
"grid": _as_str(r.get("grid", "")).strip().upper(),
|
||||
"loc": _as_str(r.get("loc", "")).strip(),
|
||||
"amount": _as_str(r.get("amount", "")).strip().replace("–", "-"),
|
||||
"chance": _as_str(r.get("chance", "")).strip().replace(" ", ""),
|
||||
})
|
||||
return out
|
||||
|
||||
def _abbr_loc(loc: str) -> str:
|
||||
"""Shorten common locations to save characters."""
|
||||
m = {
|
||||
"Imperial Testing Station": "Imp. Testing Station",
|
||||
"Large Shipwreck": "L. Shipwreck",
|
||||
"Small Shipwreck": "S. Shipwreck",
|
||||
}
|
||||
return m.get(loc.strip(), loc.strip())
|
||||
|
||||
def _grid_sort_key(g: str):
|
||||
"""Sort grids like A1, A2, B10 naturally."""
|
||||
g = g.strip().upper()
|
||||
if not g:
|
||||
return ("Z", 999)
|
||||
letter, num = g[0], g[1:]
|
||||
try:
|
||||
n = int(num)
|
||||
except Exception:
|
||||
n = 999
|
||||
return (letter, n)
|
||||
|
||||
def _fit_discord_message(lines: list[str], header: str, budget: int = 1900) -> str:
|
||||
"""Join lines under budget with a truncation notice if needed."""
|
||||
out = [header]
|
||||
total = len(header) + 1
|
||||
dropped = 0
|
||||
for ln in lines:
|
||||
ln_len = len(ln) + 1
|
||||
if total + ln_len > budget:
|
||||
dropped += 1
|
||||
continue
|
||||
out.append(ln)
|
||||
total += ln_len
|
||||
if dropped:
|
||||
out.append(f"... _(truncated {dropped} lines)_")
|
||||
return "\n".join(out)
|
||||
|
||||
def _fmt_waiting(anchor_dt: datetime) -> str:
|
||||
when = anchor_dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
return ("**Deep Desert — Weekly Uniques**\n"
|
||||
f"_Reset detected (week starting **{when}**)._\n"
|
||||
"Waiting for the new loot table to appear...\n"
|
||||
"This message will update automatically once the new data is available.")
|
||||
|
||||
def _fmt_error(anchor_dt: datetime, note: str) -> str:
|
||||
when = anchor_dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
return ("**Deep Desert — Weekly Uniques**\n"
|
||||
f"_Week starting **{when}**._\n"
|
||||
f"⚠️ {note}\n"
|
||||
f"<@{OWNER_ID}> will investigate.")
|
||||
|
||||
def _fmt_rows(rows, anchor_dt: datetime) -> str:
|
||||
from collections import OrderedDict
|
||||
|
||||
rows = _sanitize_rows(rows)
|
||||
|
||||
def _abbr_loc(loc: str) -> str:
|
||||
m = {
|
||||
"Imperial Testing Station": "Imp. Testing Station",
|
||||
"Large Shipwreck": "L. Shipwreck",
|
||||
"Small Shipwreck": "S. Shipwreck",
|
||||
}
|
||||
return m.get(loc, loc)
|
||||
|
||||
def _grid_sort_key(g: str):
|
||||
g = (g or "").upper()
|
||||
if not g: return ("Z", 999)
|
||||
letter, num = g[0], g[1:]
|
||||
try: n = int(num)
|
||||
except: n = 999
|
||||
return (letter, n)
|
||||
|
||||
# item -> location -> (amount, chance) -> [grids]
|
||||
grouped: "OrderedDict[str, OrderedDict[str, Dict[Tuple[str, str], List[str]]]]" = OrderedDict()
|
||||
for r in sorted(rows, key=lambda x: (x["name"], _abbr_loc(x["loc"]), _grid_sort_key(x["grid"]))):
|
||||
item, loc, grid, amt, ch = r["name"], _abbr_loc(r["loc"]), r["grid"], r["amount"], r["chance"]
|
||||
grouped.setdefault(item, OrderedDict()).setdefault(loc, {}).setdefault((amt, ch), []).append(grid)
|
||||
|
||||
lines = []
|
||||
for item, loc_map in grouped.items():
|
||||
lines.append(f"- **{item}**")
|
||||
for loc, by_ac in loc_map.items():
|
||||
lines.append(f" - {loc}")
|
||||
|
||||
def _sort_ac(k):
|
||||
amt, ch = k
|
||||
try:
|
||||
chv = float(ch.lstrip("~").rstrip("%"))
|
||||
except Exception:
|
||||
chv = -1.0
|
||||
return (-chv, amt)
|
||||
|
||||
for (amt, ch), grids in sorted(by_ac.items(), key=_sort_ac):
|
||||
gstr = ", ".join(sorted(set(grids), key=_grid_sort_key))
|
||||
lines.append(f" - {gstr} - {amt} ({ch})")
|
||||
|
||||
when = anchor_dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
header = f"**Deep Desert — Weekly Uniques** _(week starting **{when}**)_"
|
||||
return _fit_discord_message(lines, header, budget=1900)
|
||||
|
||||
# ---------- HTTP fetchers ----------
|
||||
|
||||
async def _fetch_via_aiohttp(session: aiohttp.ClientSession, url: str) -> str:
|
||||
headers = {
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=20, sock_connect=10, sock_read=10)
|
||||
async with session.get(url, headers=headers, allow_redirects=True, timeout=timeout) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
raise aiohttp.ClientResponseError(
|
||||
request_info=resp.request_info, history=resp.history,
|
||||
status=resp.status, message=f"HTTP {resp.status}", headers=resp.headers
|
||||
)
|
||||
return text
|
||||
|
||||
# ---------- Playwright (headless) ----------
|
||||
|
||||
class _PlaywrightPool:
|
||||
"""Lazy, optional Playwright Chromium pool (single context)."""
|
||||
def __init__(self):
|
||||
self.apw = None
|
||||
self.browser = None
|
||||
self.context = None
|
||||
self.enabled = False
|
||||
|
||||
async def ensure(self) -> bool:
|
||||
if self.enabled and self.apw and self.browser and self.context:
|
||||
return True
|
||||
try:
|
||||
from playwright.async_api import async_playwright # type: ignore
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
self.apw = await async_playwright().start()
|
||||
# flags for container/root environments + reduce automation signals
|
||||
self.browser = await self.apw.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
],
|
||||
)
|
||||
self.context = await self.browser.new_context(
|
||||
user_agent=_USER_AGENT,
|
||||
locale="en-US",
|
||||
timezone_id="UTC",
|
||||
java_script_enabled=True,
|
||||
ignore_https_errors=True,
|
||||
viewport={"width": 1366, "height": 900},
|
||||
extra_http_headers={
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
},
|
||||
)
|
||||
# Minimal stealth: remove webdriver and add a few common props
|
||||
await self.context.add_init_script("""
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5] });
|
||||
""")
|
||||
self.enabled = True
|
||||
return True
|
||||
|
||||
async def close(self):
|
||||
try:
|
||||
if self.context: await self.context.close()
|
||||
finally:
|
||||
try:
|
||||
if self.browser: await self.browser.close()
|
||||
finally:
|
||||
try:
|
||||
if self.apw: await self.apw.stop()
|
||||
finally:
|
||||
self.apw = self.browser = self.context = None
|
||||
self.enabled = False
|
||||
|
||||
async def fetch(self, url: str, timeout_ms: Optional[int] = None, wait: Optional[str] = None) -> str:
|
||||
"""
|
||||
Fetch fully rendered HTML with tolerant waiting against Cloudflare.
|
||||
Env overrides:
|
||||
SHAI_DD_PW_TIMEOUT_MS (default 45000)
|
||||
SHAI_DD_PW_WAIT = domcontentloaded|load|networkidle (default domcontentloaded)
|
||||
"""
|
||||
if not await self.ensure():
|
||||
raise RuntimeError("playwright-unavailable")
|
||||
|
||||
timeout_ms = int(os.getenv("SHAI_DD_PW_TIMEOUT_MS", "45000") or "45000") if timeout_ms is None else timeout_ms
|
||||
wait_mode = (os.getenv("SHAI_DD_PW_WAIT", "domcontentloaded") or "domcontentloaded").lower()
|
||||
if wait: wait_mode = wait
|
||||
|
||||
page = await self.context.new_page()
|
||||
|
||||
# Keep media traffic low but don't block fonts/CSS/JS (CF sometimes needs them)
|
||||
async def _route(route):
|
||||
rt = route.request.resource_type
|
||||
if rt in ("media", "video", "audio"):
|
||||
await route.abort()
|
||||
else:
|
||||
await route.continue_()
|
||||
await page.route("**/*", _route)
|
||||
|
||||
# Step 1: navigate, but don't require networkidle (CF pages rarely go "idle")
|
||||
await page.goto(url, wait_until=wait_mode, timeout=timeout_ms)
|
||||
|
||||
# Step 2: loop for CF auto-redirect and app hydration
|
||||
# We'll try up to ~35s total here.
|
||||
end_by = time.time() + max(20, timeout_ms / 1000 - 5)
|
||||
last_details = 0
|
||||
while time.time() < end_by:
|
||||
html = await page.content()
|
||||
u = page.url
|
||||
# If we're still on a CF challenge or "just a moment" page, give it a bit
|
||||
if ("cdn-cgi/challenge" in u) or ("cf-chl" in u) or ("Just a moment" in html) or ("Please wait" in html):
|
||||
await page.wait_for_timeout(2500)
|
||||
continue
|
||||
|
||||
# Check if our target content looks present
|
||||
try:
|
||||
count = await page.locator("details").count()
|
||||
except Exception:
|
||||
count = 0
|
||||
last_details = max(last_details, count)
|
||||
if count > 0:
|
||||
break
|
||||
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
html = await page.content()
|
||||
await page.close()
|
||||
return html
|
||||
|
||||
# ---------- Cog ----------
|
||||
|
||||
class DDLootTableCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
r = cfg(bot)
|
||||
self.dd_url = r.get("dd_url", DD_URL)
|
||||
try:
|
||||
self.channel_id_default = int(r.get("dd_channel_id", DD_FALLBACK_CHANNEL))
|
||||
except Exception:
|
||||
self.channel_id_default = DD_FALLBACK_CHANNEL
|
||||
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self._pw = _PlaywrightPool()
|
||||
self._last_debug: str = ""
|
||||
|
||||
async def cog_load(self):
|
||||
self._session = aiohttp.ClientSession()
|
||||
if self._task is None:
|
||||
self._task = asyncio.create_task(self._runner(), name="DDLootTableRunner")
|
||||
_log("cog loaded; runner started:", bool(self._task), "url:", self.dd_url)
|
||||
|
||||
async def cog_unload(self):
|
||||
t, self._task = self._task, None
|
||||
if t: t.cancel()
|
||||
s, self._session = self._session, None
|
||||
if s: await s.close()
|
||||
try:
|
||||
await self._pw.close()
|
||||
except Exception:
|
||||
pass
|
||||
_log("cog unloaded; runner/task closed")
|
||||
|
||||
# ---- state ----
|
||||
|
||||
def _load_state(self) -> DDState:
|
||||
st = DDState.from_dm(self.bot.data_manager)
|
||||
env_raw = os.getenv("SHAI_DD_CHANNEL_ID", "").strip().strip('"').strip("'")
|
||||
env_cid = int(env_raw) if env_raw.isdigit() else 0
|
||||
if env_cid and env_cid != st.channel_id:
|
||||
st.channel_id = env_cid
|
||||
self._save_state(st.to_row())
|
||||
_log(f"channel id overridden by ENV -> {env_cid}")
|
||||
_log(f"state loaded: ch={st.channel_id} msg={st.message_id} disabled={st.disabled}")
|
||||
return st
|
||||
|
||||
def _save_state(self, patch: Dict[str, Any]) -> None:
|
||||
dm = self.bot.data_manager
|
||||
rows = dm.get("dd_state")
|
||||
if not rows:
|
||||
dm.add("dd_state", patch); return
|
||||
def pred(_): return True
|
||||
def upd(d): d.update(patch); return d
|
||||
dm.update("dd_state", pred, upd)
|
||||
|
||||
# ---- message helpers ----
|
||||
|
||||
async def _resolve_channel(self, channel_id: int) -> Optional[discord.TextChannel]:
|
||||
ch = self.bot.get_channel(channel_id)
|
||||
if ch is None:
|
||||
try: ch = await self.bot.fetch_channel(channel_id)
|
||||
except Exception: ch = None
|
||||
if not isinstance(ch, discord.TextChannel): return None
|
||||
me = ch.guild.me
|
||||
if me:
|
||||
p = ch.permissions_for(me)
|
||||
if not (p.read_messages and p.send_messages):
|
||||
_log(f"missing perms in #{ch.name} ({ch.id})")
|
||||
return ch
|
||||
|
||||
async def _ensure_message(self, st: DDState, content_if_create: Optional[str]) -> Optional[discord.Message]:
|
||||
ch = await self._resolve_channel(st.channel_id)
|
||||
if not ch:
|
||||
_log("target channel not found/invalid:", st.channel_id)
|
||||
return None
|
||||
|
||||
if st.message_id:
|
||||
try:
|
||||
return await ch.fetch_message(st.message_id)
|
||||
except discord.NotFound:
|
||||
st.message_id = None
|
||||
self._save_state({"message_id": None})
|
||||
except discord.Forbidden:
|
||||
_log("cannot fetch message (no history); will NOT create a new one")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log("fetch_message failed:", repr(e))
|
||||
return None
|
||||
|
||||
if content_if_create is None:
|
||||
return None
|
||||
try:
|
||||
msg = await ch.send(content_if_create)
|
||||
st.message_id = msg.id
|
||||
st.last_post_hash = _hash_text(content_if_create)
|
||||
self._save_state({"channel_id": st.channel_id, "message_id": msg.id, "last_post_hash": st.last_post_hash})
|
||||
return msg
|
||||
except Exception as e:
|
||||
_log("failed to create message:", repr(e))
|
||||
return None
|
||||
|
||||
async def _set_message(self, st: DDState, content: str) -> Optional[int]:
|
||||
"""Create-or-edit the single managed message. Returns message_id (if known) and stores last_post_hash."""
|
||||
msg = await self._ensure_message(st, content_if_create=content if not st.message_id else None)
|
||||
if not msg:
|
||||
return None
|
||||
try:
|
||||
await msg.edit(content=content)
|
||||
st.last_post_hash = _hash_text(content)
|
||||
self._save_state({"last_post_hash": st.last_post_hash})
|
||||
except discord.NotFound:
|
||||
st.message_id = None
|
||||
self._save_state({"message_id": None})
|
||||
msg2 = await self._ensure_message(st, content_if_create=content)
|
||||
if msg2:
|
||||
try:
|
||||
await msg2.edit(content=content)
|
||||
st.last_post_hash = _hash_text(content)
|
||||
self._save_state({"message_id": msg2.id, "last_post_hash": st.last_post_hash})
|
||||
except Exception:
|
||||
pass
|
||||
except discord.Forbidden:
|
||||
_log("edit forbidden; single-message mode keeps state")
|
||||
except Exception as e:
|
||||
_log("edit failed:", repr(e))
|
||||
return st.message_id
|
||||
|
||||
# ---- fetch orchestration ----
|
||||
|
||||
async def _fetch_dd_html_any(self) -> Tuple[str, str]:
|
||||
"""Return (html, backend_tag). Preference: env → playwright(if available) → aiohttp."""
|
||||
prefer = os.getenv("SHAI_DD_FETCHER", "").lower()
|
||||
# prefer Playwright
|
||||
if prefer in {"playwright", "pw", "browser"}:
|
||||
if await self._pw.ensure():
|
||||
html = await self._pw.fetch(self.dd_url)
|
||||
return html, "playwright"
|
||||
else:
|
||||
# opportunistic: try Playwright first if available
|
||||
try:
|
||||
if await self._pw.ensure():
|
||||
html = await self._pw.fetch(self.dd_url)
|
||||
return html, "playwright"
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: aiohttp (may 403)
|
||||
html = await _fetch_via_aiohttp(self._session, self.dd_url)
|
||||
return html, "aiohttp"
|
||||
|
||||
async def _attempt_fetch(self) -> Tuple[bool, List[Dict[str, str]], str]:
|
||||
import asyncio
|
||||
self._last_debug = ""
|
||||
if not self._session:
|
||||
self._last_debug = "internal: no HTTP session"
|
||||
return (False, [], "unable to check for new loot (will retry)")
|
||||
try:
|
||||
html, backend = await self._fetch_dd_html_any()
|
||||
self._last_debug = f"ok via {backend}"
|
||||
except aiohttp.ClientResponseError as e:
|
||||
self._last_debug = f"http {getattr(e,'status','?')} (aiohttp)"
|
||||
return (False, [], "unable to check for new loot (will retry)")
|
||||
except asyncio.TimeoutError:
|
||||
self._last_debug = "timeout"
|
||||
return (False, [], "unable to check for new loot (will retry)")
|
||||
except Exception as e:
|
||||
self._last_debug = f"{e.__class__.__name__}: {e}"
|
||||
return (False, [], "unable to check for new loot (will retry)")
|
||||
|
||||
try:
|
||||
rows = _parse_dd_html(html)
|
||||
if not rows:
|
||||
self._last_debug = "parse: zero rows"
|
||||
return (False, [], "no loot entries detected yet (will retry)")
|
||||
clean = []
|
||||
for r in rows:
|
||||
name = r["name"].strip()
|
||||
grid = r["grid"].strip().upper()
|
||||
loc = r["loc"].strip()
|
||||
amt = r["amount"].strip().replace("–", "-")
|
||||
chance = r["chance"].strip().replace(" ", "")
|
||||
if not name or not re.match(r"^[A-Z]\d+$", grid):
|
||||
continue
|
||||
clean.append({"name": name, "grid": grid, "loc": loc, "amount": amt, "chance": chance})
|
||||
if not clean:
|
||||
self._last_debug = "parse: filtered to zero rows"
|
||||
return (False, [], "loot data format changed (will retry)")
|
||||
return (True, clean, "")
|
||||
except Exception as e:
|
||||
self._last_debug = f"parse error: {e.__class__.__name__}: {e}"
|
||||
return (False, [], "loot data parse error (will retry)")
|
||||
|
||||
# ---- manual kick ----
|
||||
|
||||
async def _manual_kick_once(self, st: DDState) -> str:
|
||||
anchor_dt = _this_week_anchor()
|
||||
|
||||
# always show "waiting" briefly so users see it's been kicked
|
||||
mid = await self._set_message(st, _fmt_waiting(anchor_dt))
|
||||
if mid and not st.message_id:
|
||||
st.message_id = mid
|
||||
self._save_state(st.to_row())
|
||||
|
||||
ok, rows, note = await self._attempt_fetch()
|
||||
if not ok or not rows:
|
||||
if note:
|
||||
await self._set_message(st, _fmt_error(anchor_dt, note))
|
||||
return f"Fetch failed: {note or 'unknown error'}"
|
||||
|
||||
new_hash = _hash_records(rows)
|
||||
|
||||
if st.prev_hash and new_hash == st.prev_hash:
|
||||
# still last week's data; keep waiting
|
||||
await self._set_message(st, _fmt_waiting(anchor_dt))
|
||||
return "Data unchanged from previous cycle; still waiting."
|
||||
|
||||
table = _fmt_rows(rows, anchor_dt)
|
||||
|
||||
if st.last_hash and new_hash == st.last_hash:
|
||||
# same as what we already posted this cycle → ensure table is visible
|
||||
await self._set_message(st, table)
|
||||
return "Data unchanged; table ensured."
|
||||
|
||||
# fresh for this cycle
|
||||
st.last_hash = new_hash
|
||||
st.last_success_ts = int(time.time())
|
||||
self._save_state(st.to_row())
|
||||
await self._set_message(st, table)
|
||||
return "Posted fresh data."
|
||||
|
||||
# ---- runner ----
|
||||
|
||||
async def _runner(self):
|
||||
await self.bot.wait_until_ready()
|
||||
_log("runner loop started")
|
||||
while not self.bot.is_closed():
|
||||
try:
|
||||
st = self._load_state()
|
||||
if st.disabled:
|
||||
await asyncio.sleep(300); continue
|
||||
|
||||
now_dt = _utcnow()
|
||||
this_anchor_dt = _this_week_anchor(now_dt)
|
||||
this_anchor_ts = int(this_anchor_dt.timestamp())
|
||||
next_anchor_dt = _next_week_anchor(now_dt)
|
||||
|
||||
if st.week_anchor_ts != this_anchor_ts:
|
||||
# roll current → prev; reset current
|
||||
st.prev_hash = st.last_hash or st.prev_hash
|
||||
st.last_hash = ""
|
||||
st.week_anchor_ts = this_anchor_ts
|
||||
st.last_success_ts = 0
|
||||
st.waiting_since_ts = this_anchor_ts
|
||||
st.last_attempt_ts = 0
|
||||
self._save_state(st.to_row())
|
||||
|
||||
mid = await self._set_message(st, _fmt_waiting(this_anchor_dt))
|
||||
if mid and not st.message_id:
|
||||
st.message_id = mid
|
||||
self._save_state(st.to_row())
|
||||
_log("new week anchor -> waiting UPDATED (single-message)")
|
||||
|
||||
if st.last_success_ts >= this_anchor_ts and st.last_success_ts < int(next_anchor_dt.timestamp()):
|
||||
await asyncio.sleep(min(3600, max(60, int(next_anchor_dt.timestamp() - time.time()))))
|
||||
continue
|
||||
|
||||
if st.waiting_since_ts == 0:
|
||||
st.waiting_since_ts = this_anchor_ts
|
||||
|
||||
delay = _backoff_delay_secs(st.waiting_since_ts, time.time())
|
||||
|
||||
if st.last_attempt_ts == 0 or (time.time() - st.last_attempt_ts) >= delay:
|
||||
ok, rows, note = await self._attempt_fetch()
|
||||
st.last_attempt_ts = int(time.time())
|
||||
self._save_state(st.to_row())
|
||||
|
||||
if ok and rows:
|
||||
new_hash = _hash_records(rows)
|
||||
|
||||
# 1) identical to last cycle → keep waiting; keep polling
|
||||
if st.prev_hash and new_hash == st.prev_hash:
|
||||
waiting = _fmt_waiting(this_anchor_dt)
|
||||
if st.last_post_hash != _hash_text(waiting):
|
||||
await self._set_message(st, waiting)
|
||||
_log("data equals prev week; still waiting")
|
||||
# no success_ts update; try again with backoff
|
||||
else:
|
||||
table = _fmt_rows(rows, this_anchor_dt)
|
||||
|
||||
# 2) same as current hash → ensure table is visible (flip off any waiting message)
|
||||
if st.last_hash and new_hash == st.last_hash:
|
||||
if st.last_post_hash != _hash_text(table):
|
||||
await self._set_message(st, table)
|
||||
_log("data same as already posted; ensured table visible")
|
||||
# already have success this cycle; sleep a bit longer
|
||||
await asyncio.sleep(900)
|
||||
continue
|
||||
|
||||
# 3) fresh data for this cycle → post table, mark success
|
||||
st.last_hash = new_hash
|
||||
st.last_success_ts = int(time.time())
|
||||
self._save_state(st.to_row())
|
||||
await self._set_message(st, table)
|
||||
_log("updated weekly uniques (fresh data)")
|
||||
await asyncio.sleep(900)
|
||||
continue
|
||||
|
||||
else:
|
||||
if note:
|
||||
await self._set_message(st, _fmt_error(this_anchor_dt, note))
|
||||
_log("fetch failed:", note, "| debug:", self._last_debug)
|
||||
|
||||
await asyncio.sleep(30)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
_log("runner error:", repr(e)); await asyncio.sleep(30)
|
||||
_log("runner loop stopped")
|
||||
|
||||
# ---- command ----
|
||||
|
||||
@app_commands.command(name="dd_update", description="[MOD] Control the Deep Desert weekly loot updater")
|
||||
@app_commands.describe(action="stop/resume/start", reason="Optional reason")
|
||||
async def dd_update(self, interaction: discord.Interaction,
|
||||
action: Literal["stop", "resume", "start"],
|
||||
reason: Optional[str] = None):
|
||||
st = self._load_state()
|
||||
is_owner = bool(interaction.guild and interaction.user.id == getattr(interaction.guild, "owner_id", 0))
|
||||
if action == "start":
|
||||
perms_ok = is_owner
|
||||
else:
|
||||
perms = interaction.user.guild_permissions if interaction.guild else None
|
||||
perms_ok = bool(is_owner or (perms and perms.manage_guild))
|
||||
if not perms_ok:
|
||||
return await interaction.response.send_message("You don't have permission to do that.", ephemeral=True)
|
||||
|
||||
if action == "stop":
|
||||
st.disabled = True; self._save_state(st.to_row())
|
||||
msg = "DD updater stopped.";
|
||||
if reason: msg += f" Reason: {reason}"
|
||||
return await interaction.response.send_message(msg, ephemeral=True)
|
||||
|
||||
if action == "resume":
|
||||
st.disabled = False; self._save_state(st.to_row())
|
||||
return await interaction.response.send_message("DD updater resumed.", ephemeral=True)
|
||||
|
||||
# start (owner-only)
|
||||
st.disabled = False
|
||||
now_dt = _utcnow()
|
||||
st.week_anchor_ts = int(_this_week_anchor(now_dt).timestamp())
|
||||
st.waiting_since_ts = int(time.time())
|
||||
st.last_attempt_ts = 0
|
||||
self._save_state(st.to_row())
|
||||
|
||||
ch = await self._resolve_channel(st.channel_id)
|
||||
if not ch:
|
||||
return await interaction.response.send_message(
|
||||
f"Manual start queued, but the target channel is invalid or missing.\n"
|
||||
f"Set **SHAI_DD_CHANNEL_ID** to a valid text channel ID (current: `{st.channel_id}`).",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
status = await self._manual_kick_once(st)
|
||||
dbg = f" (debug: {self._last_debug})" if self._last_debug else ""
|
||||
await interaction.followup.send(f"Manual start triggered. {status}{dbg}", ephemeral=True)
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(DDLootTableCog(bot))
|
0
modules/docs_site/__init__.py
Normal file
845
modules/docs_site/docs_site.py
Normal file
@ -0,0 +1,845 @@
|
||||
import json
|
||||
import threading
|
||||
import traceback
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
|
||||
from modules.common.settings import cfg
|
||||
|
||||
_START_TS = time.time()
|
||||
|
||||
# =============================
|
||||
# Safe JSON helpers
|
||||
# =============================
|
||||
|
||||
_PRIMITIVES = (str, int, float, bool, type(None))
|
||||
|
||||
def _to_primitive(obj: Any, depth: int = 0) -> Any:
|
||||
if depth > 6:
|
||||
return str(obj)
|
||||
if isinstance(obj, _PRIMITIVES):
|
||||
return obj
|
||||
if isinstance(obj, (list, tuple, set)):
|
||||
return [_to_primitive(x, depth + 1) for x in obj]
|
||||
if isinstance(obj, dict):
|
||||
out = {}
|
||||
for k, v in obj.items():
|
||||
try:
|
||||
out[str(k)] = _to_primitive(v, depth + 1)
|
||||
except Exception:
|
||||
out[str(k)] = str(v)
|
||||
return out
|
||||
if hasattr(obj, "value"):
|
||||
try:
|
||||
return _to_primitive(getattr(obj, "value"), depth + 1)
|
||||
except Exception:
|
||||
pass
|
||||
return str(obj)
|
||||
|
||||
def _json_dumps_safe(payload: Any) -> bytes:
|
||||
return json.dumps(_to_primitive(payload), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
# =============================
|
||||
# Version / uptime helpers
|
||||
# =============================
|
||||
|
||||
def _project_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
def _read_version_from_file() -> Optional[str]:
|
||||
"""Strictly parse VERSION from bot.py: VERSION = "x.y.z"."""
|
||||
try:
|
||||
bot_py = _project_root() / "bot.py"
|
||||
if not bot_py.is_file():
|
||||
return None
|
||||
text = bot_py.read_text(encoding="utf-8", errors="ignore")
|
||||
m = re.search(r'^\s*VERSION\s*=\s*["\']([^"\']+)["\']', text, re.M)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _get_version_from_botpy() -> Optional[str]:
|
||||
try:
|
||||
m = importlib.import_module("bot")
|
||||
v = getattr(m, "VERSION", None)
|
||||
if isinstance(v, (str, int, float)):
|
||||
return str(v)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _get_boot_state(bot: commands.Bot) -> Dict[str, Any]:
|
||||
dm = getattr(bot, "data_manager", None)
|
||||
if dm and dm.get("boot_state"):
|
||||
try:
|
||||
return (dm.get("boot_state") or [{}])[-1]
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _status_payload(bot: commands.Bot) -> Dict[str, Any]:
|
||||
st = _get_boot_state(bot)
|
||||
now = time.time()
|
||||
boot_ts = float(st.get("last_boot_ts", 0.0)) if st else 0.0
|
||||
if boot_ts > 0:
|
||||
uptime = max(0, int(now - boot_ts))
|
||||
else:
|
||||
uptime = max(0, int(now - _START_TS))
|
||||
|
||||
ver = st.get("last_version") if st else None
|
||||
if not ver:
|
||||
ver = _read_version_from_file() or _get_version_from_botpy() or os.getenv("SHAI_VERSION", "unknown")
|
||||
return {"uptime_seconds": uptime, "version": str(ver)}
|
||||
|
||||
|
||||
# =============================
|
||||
# Mod detection (heuristics)
|
||||
# =============================
|
||||
|
||||
def _looks_like_mod_check(fn) -> bool:
|
||||
try:
|
||||
qn = getattr(fn, "__qualname__", "") or ""
|
||||
mod_names = (
|
||||
"is_moderator_member",
|
||||
"is_moderator_userid",
|
||||
"require_mod_ctx",
|
||||
"require_mod_interaction",
|
||||
)
|
||||
if any(m in qn for m in mod_names):
|
||||
return True
|
||||
mod = getattr(fn, "__module__", "") or ""
|
||||
if "mod_perms" in mod:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _is_perm_check(fn) -> Tuple[bool, List[str]]:
|
||||
perms = []
|
||||
try:
|
||||
code = getattr(fn, "__code__", None)
|
||||
if code and code.co_freevars and getattr(fn, "__closure__", None):
|
||||
for cell in fn.__closure__ or []:
|
||||
val = getattr(cell, "cell_contents", None)
|
||||
if isinstance(val, dict):
|
||||
for k, v in val.items():
|
||||
if v:
|
||||
perms.append(str(k))
|
||||
except Exception:
|
||||
pass
|
||||
return (len(perms) > 0), perms
|
||||
|
||||
|
||||
# =============================
|
||||
# Introspection
|
||||
# =============================
|
||||
|
||||
def _bot_prefix(bot: commands.Bot) -> str:
|
||||
p = getattr(bot, "command_prefix", "!")
|
||||
try:
|
||||
if callable(p):
|
||||
val = p(bot, None)
|
||||
if isinstance(val, (list, tuple)) and val:
|
||||
return str(val[0])
|
||||
return str(val)
|
||||
return str(p)
|
||||
except Exception:
|
||||
return "!"
|
||||
|
||||
def _is_mod_command_prefix(cmd: commands.Command) -> Tuple[bool, List[str]]:
|
||||
is_mod = False
|
||||
perms: List[str] = []
|
||||
try:
|
||||
for chk in getattr(cmd, "checks", []) or []:
|
||||
if _looks_like_mod_check(chk):
|
||||
is_mod = True
|
||||
p_flag, p_list = _is_perm_check(chk)
|
||||
if p_flag:
|
||||
perms.extend(p_list)
|
||||
except Exception:
|
||||
pass
|
||||
return is_mod, sorted(set(perms))
|
||||
|
||||
def _is_mod_command_slash(cmd: app_commands.Command) -> Tuple[bool, List[str]]:
|
||||
is_mod = False
|
||||
try:
|
||||
for chk in getattr(cmd, "checks", []) or []:
|
||||
if _looks_like_mod_check(chk):
|
||||
is_mod = True
|
||||
for chk in getattr(cmd, "_checks", []) or []:
|
||||
if _looks_like_mod_check(chk):
|
||||
is_mod = True
|
||||
except Exception:
|
||||
pass
|
||||
return is_mod, []
|
||||
|
||||
def _command_usage_prefix(cmd: commands.Command, prefix: str) -> str:
|
||||
if cmd.usage:
|
||||
return cmd.usage
|
||||
try:
|
||||
params = []
|
||||
for k, p in (cmd.clean_params or {}).items():
|
||||
if getattr(p, "kind", None) and str(p.kind).lower().startswith("var"):
|
||||
params.append(f"[{k}...]")
|
||||
elif p.default is p.empty:
|
||||
params.append(f"<{k}>")
|
||||
else:
|
||||
params.append(f"[{k}]")
|
||||
return f"{prefix}{cmd.qualified_name}" + ((" " + " ".join(params)) if params else "")
|
||||
except Exception:
|
||||
return f"{prefix}{cmd.name}"
|
||||
|
||||
def _command_usage_slash_like(cmd_name: str, options: Optional[List[Any]] = None) -> str:
|
||||
try:
|
||||
parts = [f"/{cmd_name}"]
|
||||
opts = []
|
||||
seq = options or []
|
||||
for opt in seq:
|
||||
n = getattr(opt, "name", None) or getattr(opt, "display_name", None) or "arg"
|
||||
req = bool(getattr(opt, "required", False))
|
||||
opts.append(f"<{n}>" if req else f"[{n}]")
|
||||
if opts:
|
||||
parts.append(" " + " ".join(opts))
|
||||
return "".join(parts)
|
||||
except Exception:
|
||||
return f"/{cmd_name}"
|
||||
|
||||
def _command_usage_slash(cmd: app_commands.Command) -> str:
|
||||
try:
|
||||
options = getattr(cmd, "options", None) or getattr(cmd, "parameters", None) or getattr(cmd, "_params", None)
|
||||
return _command_usage_slash_like((cmd.name or "").replace("/", " "), options)
|
||||
except Exception:
|
||||
return f"/{cmd.name}"
|
||||
|
||||
def _iter_all_app_commands(bot: commands.Bot):
|
||||
"""Yield (scope_tag, top_level_command) including global and per-guild trees."""
|
||||
out = []
|
||||
try:
|
||||
for cmd in bot.tree.get_commands():
|
||||
out.append(("", cmd))
|
||||
except Exception:
|
||||
pass
|
||||
for g in list(getattr(bot, "guilds", []) or []):
|
||||
try:
|
||||
cmds = bot.tree.get_commands(guild=g)
|
||||
except TypeError:
|
||||
try:
|
||||
cmds = bot.tree.get_commands(guild=discord.Object(id=g.id))
|
||||
except Exception:
|
||||
cmds = []
|
||||
except Exception:
|
||||
cmds = []
|
||||
for cmd in cmds or []:
|
||||
out.append((str(g.id), cmd))
|
||||
return out
|
||||
|
||||
def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
prefix = _bot_prefix(bot)
|
||||
|
||||
for cmd in getattr(bot, "commands", []) or []:
|
||||
try:
|
||||
if getattr(cmd, "hidden", False):
|
||||
continue
|
||||
is_hybrid = isinstance(cmd, commands.HybridCommand)
|
||||
ctype = "hybrid" if is_hybrid else "prefix"
|
||||
|
||||
is_mod, perms = _is_mod_command_prefix(cmd)
|
||||
|
||||
usage_slash = None
|
||||
if is_hybrid:
|
||||
try:
|
||||
app_cmd = getattr(cmd, "app_command", None)
|
||||
if isinstance(app_cmd, app_commands.Command):
|
||||
usage_slash = _command_usage_slash(app_cmd)
|
||||
s_mod, _ = _is_mod_command_slash(app_cmd)
|
||||
if s_mod:
|
||||
is_mod = True
|
||||
desc = (getattr(app_cmd, "description", "") or "")
|
||||
if "[mod]" in desc.lower():
|
||||
is_mod = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
usage_prefix = _command_usage_prefix(cmd, prefix)
|
||||
qn = cmd.qualified_name # single source of truth for counters
|
||||
|
||||
row = {
|
||||
"type": ctype,
|
||||
"name": cmd.qualified_name,
|
||||
"display_name": cmd.qualified_name,
|
||||
"help": (cmd.help or "").strip(),
|
||||
"brief": (cmd.brief or "").strip(),
|
||||
"usage": usage_prefix if not is_hybrid else None,
|
||||
"usage_prefix": usage_prefix,
|
||||
"usage_slash": usage_slash,
|
||||
"cog": getattr(cmd.cog, "qualified_name", None) if getattr(cmd, "cog", None) else None,
|
||||
"module": getattr(getattr(cmd, "callback", None), "__module__", None),
|
||||
"moderator_only": bool(is_mod),
|
||||
"admin_only": False,
|
||||
"required_permissions": perms,
|
||||
"counter_key": qn,
|
||||
"exec_count": _cmd_counter(bot, qn),
|
||||
}
|
||||
key = ("px", row["name"])
|
||||
if key not in seen:
|
||||
rows.append(row)
|
||||
seen.add(key)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
return rows
|
||||
|
||||
def _walk_app_tree(node: Any, prefix: str = "") -> List[Tuple[str, app_commands.Command]]:
|
||||
out: List[Tuple[str, app_commands.Command]] = []
|
||||
if isinstance(node, app_commands.Command):
|
||||
out.append((f"{prefix}/{node.name}", node))
|
||||
return out
|
||||
if isinstance(node, app_commands.Group):
|
||||
base = f"{prefix}/{node.name}"
|
||||
for sub in list(getattr(node, "commands", []) or []):
|
||||
out.extend(_walk_app_tree(sub, base))
|
||||
return out
|
||||
|
||||
def _safe_extras(obj: Any) -> Optional[Dict[str, Any]]:
|
||||
d = getattr(obj, "extras", None)
|
||||
if not d:
|
||||
return None
|
||||
if not isinstance(d, dict):
|
||||
return {"value": _to_primitive(d)}
|
||||
return _to_primitive(d)
|
||||
|
||||
def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
collected: List[Tuple[str, app_commands.Command, str]] = []
|
||||
try:
|
||||
for scope, top in _iter_all_app_commands(bot):
|
||||
for path, leaf in _walk_app_tree(top, prefix=""):
|
||||
collected.append((scope, leaf, path))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
seen_paths = set()
|
||||
for scope, leaf, path in collected:
|
||||
try:
|
||||
canon = path.lstrip("/") # e.g., 'power/restart'
|
||||
if canon in seen_paths:
|
||||
continue
|
||||
seen_paths.add(canon)
|
||||
|
||||
is_mod, perms = _is_mod_command_slash(leaf)
|
||||
binding = getattr(leaf, "binding", None)
|
||||
callback = getattr(leaf, "callback", None)
|
||||
|
||||
display = canon.replace("/", " ")
|
||||
options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None)
|
||||
usage_full = _command_usage_slash_like(display, options)
|
||||
|
||||
# Use leaf.qualified_name when available, it matches listener keys
|
||||
qn = getattr(leaf, "qualified_name", None) or display
|
||||
|
||||
row = {
|
||||
"type": "slash",
|
||||
"name": "/" + canon,
|
||||
"display_name": "/" + display,
|
||||
"help": (getattr(leaf, "description", "") or "").strip(),
|
||||
"brief": "",
|
||||
"usage": usage_full,
|
||||
"usage_prefix": None,
|
||||
"usage_slash": usage_full,
|
||||
"cog": binding.__class__.__name__ if binding else None,
|
||||
"module": getattr(callback, "__module__", None) if callback else None,
|
||||
"moderator_only": bool(is_mod),
|
||||
"admin_only": False,
|
||||
"required_permissions": perms,
|
||||
"extras": _safe_extras(leaf),
|
||||
"dm_permission": getattr(leaf, "dm_permission", None),
|
||||
"counter_key": qn,
|
||||
"exec_count": _cmd_counter(bot, qn),
|
||||
}
|
||||
rows.append(row)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
return rows
|
||||
|
||||
def _cmd_counter(bot, qualified_name: str) -> int:
|
||||
dm = getattr(bot, "data_manager", None)
|
||||
return dm.get_counter(f"cmd::{qualified_name}") if dm else 0
|
||||
|
||||
# =============================
|
||||
# Details loader & master JSON
|
||||
# =============================
|
||||
|
||||
_DETAILS_DIR = _project_root() / "assets" / "docs" / "commands"
|
||||
_MASTER_PATH = _DETAILS_DIR / "__commands__.json"
|
||||
|
||||
_DOCS_CACHE: Dict[str, Any] = {"map": {}, "sig": ""}
|
||||
|
||||
def _row_key_candidates(row: Dict[str, Any]) -> List[str]:
|
||||
c = str(row.get("cog") or "").strip()
|
||||
if row.get("type") == "slash":
|
||||
base = str(row.get("name", "")).lstrip("/").split("/")[-1]
|
||||
else:
|
||||
base = str(row.get("name", "")).split(" ")[0]
|
||||
keys = []
|
||||
if c:
|
||||
keys.append(f"{c}.{base}")
|
||||
if row.get("type") == "slash":
|
||||
keys.append(str(row.get("name", "")).lstrip("/"))
|
||||
keys.append(base)
|
||||
return keys
|
||||
|
||||
def _scan_doc_files() -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Scans assets/docs/commands for:
|
||||
- <key>.md -> details_md
|
||||
- <key>.html -> details_html
|
||||
- <key>.brief.html -> brief_html
|
||||
- <key>.details.html -> details_html (wins over <key>.html)
|
||||
"""
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
if not _DETAILS_DIR.is_dir():
|
||||
return out
|
||||
|
||||
def _put(key: str, field: str, text: str):
|
||||
if key not in out:
|
||||
out[key] = {}
|
||||
out[key][field] = text
|
||||
|
||||
# .md legacy
|
||||
for p in _DETAILS_DIR.glob("*.md"):
|
||||
if p.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
_put(p.stem, "details_md", p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
# .html brief/details
|
||||
for p in _DETAILS_DIR.glob("*.html"):
|
||||
if p.name.startswith("_"):
|
||||
continue
|
||||
name = p.stem
|
||||
try:
|
||||
txt = p.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
if name.endswith(".brief"):
|
||||
key = name[:-6]
|
||||
_put(key, "brief_html", txt)
|
||||
elif name.endswith(".details"):
|
||||
key = name[:-8]
|
||||
_put(key, "details_html", txt)
|
||||
else:
|
||||
_put(name, "details_html", txt)
|
||||
|
||||
return out
|
||||
|
||||
def _load_master_json() -> Dict[str, Dict[str, Any]]:
|
||||
if not _MASTER_PATH.is_file():
|
||||
return {}
|
||||
try:
|
||||
raw_text = _MASTER_PATH.read_text(encoding="utf-8")
|
||||
if not raw_text.strip():
|
||||
return {}
|
||||
raw_any = json.loads(raw_text) or {}
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for k, v in raw_any.items():
|
||||
if isinstance(v, dict):
|
||||
out[str(k)] = {kk: vv for kk, vv in v.items()}
|
||||
return out
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return {}
|
||||
|
||||
def _write_master_json(mapping: Dict[str, Dict[str, Any]]) -> None:
|
||||
try:
|
||||
_DETAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with _MASTER_PATH.open("w", encoding="utf-8") as f:
|
||||
json.dump(mapping, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
def _dir_signature() -> str:
|
||||
parts = []
|
||||
if _DETAILS_DIR.is_dir():
|
||||
for p in sorted(_DETAILS_DIR.glob("*")):
|
||||
try:
|
||||
parts.append(f"{p.name}:{int(p.stat().st_mtime)}")
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
if _MASTER_PATH.exists():
|
||||
parts.append(f"__master__:{int(_MASTER_PATH.stat().st_mtime)}")
|
||||
except Exception:
|
||||
pass
|
||||
return "|".join(parts)
|
||||
|
||||
def _load_external_docs() -> Dict[str, Dict[str, Any]]:
|
||||
sig = _dir_signature()
|
||||
if _DOCS_CACHE.get("sig") == sig:
|
||||
return _DOCS_CACHE["map"]
|
||||
|
||||
master = _load_master_json()
|
||||
doc_map = _scan_doc_files()
|
||||
|
||||
merged: Dict[str, Dict[str, Any]] = {k: dict(v) for k, v in master.items()}
|
||||
for k, v in doc_map.items():
|
||||
if k not in merged:
|
||||
merged[k] = {}
|
||||
for kk, vv in v.items():
|
||||
merged[k][kk] = vv
|
||||
|
||||
_write_master_json(merged)
|
||||
_DOCS_CACHE["map"] = merged
|
||||
_DOCS_CACHE["sig"] = sig
|
||||
return merged
|
||||
|
||||
def _augment_with_external_docs(rows: List[Dict[str, Any]]) -> None:
|
||||
mapping = _load_external_docs()
|
||||
for r in rows:
|
||||
if not r.get("details_md") and isinstance(r.get("extras"), dict):
|
||||
dm = r["extras"].get("details_md")
|
||||
if isinstance(dm, str) and dm.strip():
|
||||
r["details_md"] = dm
|
||||
if not r.get("details_md") or not r.get("brief_html") or not r.get("details_html"):
|
||||
for key in _row_key_candidates(r):
|
||||
if key in mapping and isinstance(mapping[key], dict):
|
||||
m = mapping[key]
|
||||
if not r.get("details_md") and isinstance(m.get("details_md"), str):
|
||||
r["details_md"] = m["details_md"]
|
||||
if not r.get("brief_html") and isinstance(m.get("brief_html"), str):
|
||||
r["brief_html"] = m["brief_html"]
|
||||
if not r.get("details_html") and isinstance(m.get("details_html"), str):
|
||||
r["details_html"] = m["details_html"]
|
||||
if not r.get("doc_meta"):
|
||||
meta = {kk: vv for kk, vv in m.items() if kk not in {"details_md","brief_html","details_html"}}
|
||||
if meta:
|
||||
r["doc_meta"] = meta
|
||||
break
|
||||
|
||||
|
||||
# =============================
|
||||
# Merge hybrids with their slash twins
|
||||
# =============================
|
||||
|
||||
def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
|
||||
idx_by_hybrid: Dict[Tuple[str, str], int] = {}
|
||||
for i, r in enumerate(rows):
|
||||
if r.get("type") == "hybrid":
|
||||
cog = (r.get("cog") or "").strip()
|
||||
base = str(r.get("name") or "").split(" ")[-1].lower()
|
||||
idx_by_hybrid[(cog, base)] = i
|
||||
|
||||
to_remove: List[int] = []
|
||||
for i, r in enumerate(rows):
|
||||
if r.get("type") != "slash":
|
||||
continue
|
||||
base = str(r.get("name") or "").lstrip("/").split("/")[-1].lower()
|
||||
cog = (r.get("cog") or "").strip()
|
||||
key = (cog, base)
|
||||
hi = idx_by_hybrid.get(key)
|
||||
if hi is None:
|
||||
continue
|
||||
h = rows[hi]
|
||||
if not h.get("usage_slash") and r.get("usage_slash"):
|
||||
h["usage_slash"] = r["usage_slash"]
|
||||
if not h.get("help") and r.get("help"):
|
||||
h["help"] = r["help"]
|
||||
if r.get("moderator_only"):
|
||||
h["moderator_only"] = True
|
||||
if r.get("admin_only"):
|
||||
h["admin_only"] = True
|
||||
if r.get("required_permissions"):
|
||||
h["required_permissions"] = sorted(set((h.get("required_permissions") or []) + r["required_permissions"]))
|
||||
if not h.get("extras") and r.get("extras"):
|
||||
h["extras"] = r["extras"]
|
||||
if r.get("details_md") and not h.get("details_md"):
|
||||
h["details_md"] = r["details_md"]
|
||||
if r.get("brief_html") and not h.get("brief_html"):
|
||||
h["brief_html"] = r["brief_html"]
|
||||
if r.get("details_html") and not h.get("details_html"):
|
||||
h["details_html"] = r["details_html"]
|
||||
|
||||
# NEW: sum exec_count from slash twin into the hybrid row
|
||||
h["exec_count"] = int(h.get("exec_count", 0) or 0) + int(r.get("exec_count", 0) or 0)
|
||||
|
||||
to_remove.append(i)
|
||||
|
||||
for i in sorted(to_remove, reverse=True):
|
||||
rows.pop(i)
|
||||
|
||||
|
||||
# =============================
|
||||
# Schema builder
|
||||
# =============================
|
||||
|
||||
def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
|
||||
px = _gather_prefix_and_hybrid(bot)
|
||||
sl = _gather_slash(bot)
|
||||
all_rows = px + sl
|
||||
|
||||
# Mark mod-only via hints/perms/extras
|
||||
for row in all_rows:
|
||||
try:
|
||||
helptext = f"{row.get('help') or ''} {row.get('brief') or ''}"
|
||||
hl = helptext.lower()
|
||||
if "[mod]" in hl:
|
||||
row["moderator_only"] = True
|
||||
if "[admin]" in hl:
|
||||
row["admin_only"] = True
|
||||
except Exception:
|
||||
pass
|
||||
if row.get("required_permissions"):
|
||||
row["moderator_only"] = True
|
||||
try:
|
||||
ex = row.get("extras") or {}
|
||||
if isinstance(ex, dict) and str(ex.get("category", "")).lower() in {"mod", "moderator", "staff"}:
|
||||
row["moderator_only"] = True
|
||||
if isinstance(ex, dict) and str(ex.get("category", "")).lower() in {"admin", "administrator", "owner"}:
|
||||
row["admin_only"] = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_augment_with_external_docs(all_rows)
|
||||
_merge_hybrid_slash(all_rows)
|
||||
|
||||
# ✅ NEW: sort alphabetically by *command name* (display_name/name), not by cog
|
||||
def _sort_key(r: Dict[str, Any]) -> str:
|
||||
s = (r.get("display_name") or r.get("name") or "")
|
||||
# strip leading slash and normalize
|
||||
return s.lstrip("/").strip().lower()
|
||||
|
||||
all_rows.sort(key=_sort_key)
|
||||
|
||||
mods = [r for r in all_rows if r.get("moderator_only") or r.get("admin_only")]
|
||||
users = [r for r in all_rows if not (r.get("moderator_only") or r.get("admin_only"))]
|
||||
|
||||
return {
|
||||
"title": "ShaiWatcher Commands",
|
||||
"count": len(all_rows),
|
||||
"sections": {"user": users, "moderator": mods},
|
||||
"all": all_rows,
|
||||
}
|
||||
|
||||
# =============================
|
||||
# Static asset serving
|
||||
# =============================
|
||||
|
||||
def _static_root() -> Path:
|
||||
return _project_root() / "assets" / "docs"
|
||||
|
||||
def _guess_mime(p: Path) -> str:
|
||||
ext = p.suffix.lower()
|
||||
return {
|
||||
".svg": "image/svg+xml; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".html": "text/html; charset=utf-8",
|
||||
}.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
# =============================
|
||||
# HTTP + UI
|
||||
# =============================
|
||||
|
||||
class _DocsHandler(BaseHTTPRequestHandler):
|
||||
bot: commands.Bot = None
|
||||
title: str = "ShaiWatcher Commands"
|
||||
force_ready: bool = False
|
||||
support_url: Optional[str] = None
|
||||
support_label: Optional[str] = None
|
||||
|
||||
def _set(self, code=200, content_type="text/html; charset=utf-8"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
def _serve_file(self, fs_path: Path, cache: str = "public, max-age=86400, immutable"):
|
||||
mime = _guess_mime(fs_path)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mime)
|
||||
self.send_header("Cache-Control", cache)
|
||||
self.end_headers()
|
||||
with fs_path.open("rb") as f:
|
||||
self.wfile.write(f.read())
|
||||
|
||||
def do_GET(self):
|
||||
try:
|
||||
ready = bool(self.force_ready) or (getattr(self, "bot", None) and self.bot.is_ready())
|
||||
if not ready:
|
||||
self._set(503, "text/plain; charset=utf-8")
|
||||
self.wfile.write(b"warming up")
|
||||
return
|
||||
|
||||
path = urlparse(self.path).path
|
||||
|
||||
# Root favicon / PWA convenience routes
|
||||
if path in ("/favicon.ico", "/favicon-16x16.png", "/favicon-32x32.png",
|
||||
"/apple-touch-icon.png", "/site.webmanifest"):
|
||||
root = _static_root().resolve()
|
||||
name = path.lstrip("/")
|
||||
fs_path = (root / name).resolve()
|
||||
try:
|
||||
if not fs_path.is_relative_to(root):
|
||||
raise ValueError("outside root")
|
||||
except AttributeError:
|
||||
if str(root) not in str(fs_path):
|
||||
raise ValueError("outside root")
|
||||
if fs_path.is_file():
|
||||
self._serve_file(fs_path)
|
||||
else:
|
||||
self._set(404, "text/plain; charset=utf-8"); self.wfile.write(b"not found")
|
||||
return
|
||||
|
||||
# Static assets under /assets/docs/...
|
||||
if path.startswith("/assets/docs/"):
|
||||
try:
|
||||
root = _static_root().resolve()
|
||||
rel = path[len("/assets/docs/"):]
|
||||
fs_path = (root / rel).resolve()
|
||||
try:
|
||||
if not fs_path.is_relative_to(root):
|
||||
raise ValueError("outside root")
|
||||
except AttributeError:
|
||||
if str(root) not in str(fs_path):
|
||||
raise ValueError("outside root")
|
||||
if fs_path.is_file():
|
||||
self._serve_file(fs_path)
|
||||
return
|
||||
self._set(404, "text/plain; charset=utf-8"); self.wfile.write(b"not found")
|
||||
return
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
self._set(500, "text/plain; charset=utf-8"); self.wfile.write(b"internal error")
|
||||
return
|
||||
|
||||
if path == "/":
|
||||
# Load template file
|
||||
tpl_path = _static_root() / "cmd.html"
|
||||
if tpl_path.is_file():
|
||||
html = tpl_path.read_text(encoding="utf-8")
|
||||
else:
|
||||
# Fallback minimal page if template missing
|
||||
html = "<!doctype html><meta charset='utf-8'><title>Docs</title><body><div id='alerts'>Missing assets/docs/cmd.html</div></body>"
|
||||
|
||||
# Inject data + support placeholders
|
||||
try:
|
||||
schema = build_command_schema(self.bot)
|
||||
inline = json.dumps(_to_primitive(schema), ensure_ascii=False, separators=(",", ":"))
|
||||
# Insert __DATA__ just before </head>
|
||||
inj = f"<script>window.__DATA__={inline};</script>"
|
||||
html = html.replace("</head>", inj + "</head>")
|
||||
support_url = getattr(_DocsHandler, "support_url", "") or ""
|
||||
support_label = getattr(_DocsHandler, "support_label", "Buy me a ☕")
|
||||
vis = "block" if support_url else "none"
|
||||
html = (html.replace("__TITLE__", self.title)
|
||||
.replace("__SUPPORT_URL__", support_url)
|
||||
.replace("__SUPPORT_LABEL__", support_label)
|
||||
.replace("__SUPPORT_VIS__", vis))
|
||||
self._set()
|
||||
self.wfile.write(html.encode("utf-8"))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
self._set(500, "text/plain; charset=utf-8")
|
||||
self.wfile.write(b"internal error")
|
||||
return
|
||||
|
||||
if path == "/api/status":
|
||||
payload = _status_payload(self.bot)
|
||||
self._set(200, "application/json; charset=utf-8")
|
||||
self.wfile.write(_json_dumps_safe(payload))
|
||||
return
|
||||
|
||||
if path == "/healthz":
|
||||
self._set(200, "text/plain; charset=utf-8"); self.wfile.write(b"ok"); return
|
||||
|
||||
if path == "/api/commands":
|
||||
schema = build_command_schema(self.bot)
|
||||
payload = _json_dumps_safe(schema)
|
||||
self._set(200, "application/json; charset=utf-8")
|
||||
self.wfile.write(payload)
|
||||
return
|
||||
|
||||
self._set(404, "text/plain; charset=utf-8"); self.wfile.write(b"not found")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
try:
|
||||
self._set(500, "text/plain; charset=utf-8"); self.wfile.write(b"internal error")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _start_server(bot: commands.Bot, host: str, port: int, title: str):
|
||||
_DocsHandler.bot = bot
|
||||
_DocsHandler.title = title
|
||||
_DocsHandler.support_url = getattr(bot, "docs_support_url", None)
|
||||
_DocsHandler.support_label = getattr(bot, "docs_support_label", None)
|
||||
_DocsHandler.force_ready = os.getenv("SHAI_OFFLINE", "").lower() in {"1", "true", "yes"}
|
||||
httpd = ThreadingHTTPServer((host, port), _DocsHandler)
|
||||
|
||||
def _run():
|
||||
try:
|
||||
httpd.serve_forever(poll_interval=0.5)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
t = threading.Thread(target=_run, name="DocsSite", daemon=True)
|
||||
t.start()
|
||||
print(f"[DocsSite] Listening on http://{host}:{port} (title='{title}', offline={_DocsHandler.force_ready})")
|
||||
|
||||
class DocsSite(commands.Cog):
|
||||
"""Tiny Swagger-like docs site for bot commands."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
r = cfg(bot)
|
||||
|
||||
self.host = r.get("docs_host", "0.0.0.0") # SHAI_DOCS_HOST
|
||||
self.port = r.int("docs_port", 8910) # SHAI_DOCS_PORT
|
||||
self.title = r.get("docs_title", "ShaiWatcher Commands") # SHAI_DOCS_TITLE
|
||||
|
||||
# Support link config
|
||||
self.support_url = r.get("docs_support_url", "https://throne.com/ookamikuntv/item/39590391-c582-4c5d-8795-fe6f1925eaae")
|
||||
self.support_label = r.get("docs_support_label", "Buy me a ☕")
|
||||
|
||||
# Expose to handler
|
||||
self.bot.docs_support_url = self.support_url
|
||||
self.bot.docs_support_label = self.support_label
|
||||
|
||||
_start_server(self.bot, self.host, self.port, self.title)
|
||||
|
||||
def force_ready(self, value: bool = True):
|
||||
_DocsHandler.force_ready = bool(value)
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(DocsSite(bot))
|
@ -1,45 +1,56 @@
|
||||
# modules/nick_nudge/nick_nudge.py
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
from collections import defaultdict
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
|
||||
from mod_perms import is_moderator_userid
|
||||
from mod_perms import is_moderator_userid, require_mod_interaction
|
||||
from modules.common.emoji_accept import is_accept
|
||||
from modules.common.settings import cfg # ENV-first config helper
|
||||
|
||||
CHECK = '✅' # approved/verified
|
||||
CROSS = '❌' # reject / no
|
||||
PENDING = '✔️' # heavy check mark = pending claim
|
||||
ACCEPT = {CHECK, '🫡'}
|
||||
ACCEPT = {CHECK, '🫡'}
|
||||
NO_MENTIONS = discord.AllowedMentions.none()
|
||||
|
||||
# Per-user in-process lock to prevent duplicate reviews from concurrent reactions
|
||||
_user_locks = defaultdict(asyncio.Lock)
|
||||
|
||||
|
||||
def _ts_rel(ts: Optional[float] = None) -> str:
|
||||
"""Discord relative timestamp like <t:12345:R>."""
|
||||
if ts is None:
|
||||
ts = time.time()
|
||||
return f"<t:{int(ts)}:R>"
|
||||
|
||||
|
||||
class NickNudgeCog(commands.Cog):
|
||||
"""
|
||||
Handles:
|
||||
• DM nickname nudge loop (optional; unchanged behavior)
|
||||
• Nickname *review* workflow for claims:
|
||||
- On claim (via reaction or /nick_same): create a mod review in mod_channel with ✅/❌
|
||||
- Mods react: ✅ -> mark verified; ❌ -> clear claim and revoke Full Access
|
||||
• Stores review mapping in data_manager['nick_reviews']
|
||||
Nickname review flow:
|
||||
- Atomic transition to 'pending' and open exactly ONE review.
|
||||
- Mods: ✅ approve -> mark verified; ❌ reject -> clear claim.
|
||||
- If a verified user changes their nickname, verification is revoked automatically.
|
||||
Data keys used in data_manager:
|
||||
• agreed_nickname: [user_id]
|
||||
• nick_claim_pending: [user_id]
|
||||
• nick_verified: [user_id]
|
||||
• nick_reviews: [{ message_id, guild_id, user_id, status, ... }]
|
||||
• nick_verified_name: [{ guild_id, user_id, nick, ts }]
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
cfg = bot.config['DEFAULT']
|
||||
self.modlog_channel_id = int(cfg['modlog_channel_id'])
|
||||
self.mod_channel_id = int(cfg['mod_channel_id']) # same review channel as pirate reports
|
||||
# Optional DM nudge loop retained
|
||||
try:
|
||||
self.loop_enabled = cfg.getboolean('nick_nudge_loop_enabled')
|
||||
except Exception:
|
||||
self.loop_enabled = False
|
||||
r = cfg(bot)
|
||||
|
||||
self.modlog_channel_id = r.int('modlog_channel_id', 0)
|
||||
self.mod_channel_id = r.int('mod_channel_id', 0)
|
||||
|
||||
self.loop_enabled = r.bool('nick_nudge_loop_enabled', False)
|
||||
self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None
|
||||
|
||||
# ---------- utils ----------
|
||||
@ -52,6 +63,8 @@ class NickNudgeCog(commands.Cog):
|
||||
pass
|
||||
|
||||
async def _modlog(self, guild: discord.Guild, content: str):
|
||||
if not self.modlog_channel_id:
|
||||
return
|
||||
ch = guild.get_channel(self.modlog_channel_id)
|
||||
if ch:
|
||||
try:
|
||||
@ -61,10 +74,6 @@ class NickNudgeCog(commands.Cog):
|
||||
self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content})
|
||||
|
||||
async def _find_last_nick_change(self, guild: discord.Guild, member: discord.Member) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Best-effort: look up last nickname change via audit logs.
|
||||
Returns (before_nick, after_nick) or (None, None) if not found/allowed.
|
||||
"""
|
||||
try:
|
||||
async for entry in guild.audit_logs(limit=10, action=discord.AuditLogAction.member_update):
|
||||
if entry.target.id != member.id or not entry.changes:
|
||||
@ -77,24 +86,60 @@ class NickNudgeCog(commands.Cog):
|
||||
pass
|
||||
return None, None
|
||||
|
||||
# ---------- public API (called by ReactionRole cog) ----------
|
||||
# ---------- atomic entry point used by all claim sources ----------
|
||||
|
||||
async def ensure_pending_and_maybe_open(self, guild: discord.Guild, member: discord.Member, source: str):
|
||||
"""
|
||||
Atomically:
|
||||
- set pending (idempotent)
|
||||
- open ONE review if this is the first transition to pending
|
||||
Prevents duplicate reviews when multiple reactions/commands fire.
|
||||
"""
|
||||
if not guild or not self.mod_channel_id or member.bot:
|
||||
return
|
||||
|
||||
newly_pending = False
|
||||
lock = _user_locks[member.id]
|
||||
async with lock:
|
||||
dm = self.bot.data_manager
|
||||
|
||||
# If a pending review already exists, bail out
|
||||
for r in dm.get('nick_reviews'):
|
||||
if r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending':
|
||||
return
|
||||
|
||||
# Mark "agreed" and flip to pending if not already
|
||||
if member.id not in dm.get('agreed_nickname'):
|
||||
dm.add('agreed_nickname', int(member.id))
|
||||
dm.remove('nick_verified', lambda x: x == member.id)
|
||||
if member.id not in dm.get('nick_claim_pending'):
|
||||
dm.add('nick_claim_pending', int(member.id))
|
||||
newly_pending = True
|
||||
|
||||
if newly_pending:
|
||||
try:
|
||||
await self.start_nick_review(guild, member, source=source)
|
||||
except Exception:
|
||||
# Roll back pending on failure so the user can try again
|
||||
try:
|
||||
dm = self.bot.data_manager
|
||||
dm.remove('nick_claim_pending', lambda x: x == member.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------- public API ----------
|
||||
|
||||
async def start_nick_review(self, guild: discord.Guild, member: discord.Member, source: str = "claim"):
|
||||
"""
|
||||
Create (or update) a nickname review entry for this member in the mod channel.
|
||||
- source: "claim" or "nick_same"
|
||||
Stores in data_manager['nick_reviews'] a record keyed by the review message_id.
|
||||
"""
|
||||
if not guild:
|
||||
if not guild or not self.mod_channel_id:
|
||||
return
|
||||
|
||||
mod_ch = guild.get_channel(self.mod_channel_id)
|
||||
if not mod_ch:
|
||||
return
|
||||
|
||||
before_n, after_n = await self._find_last_nick_change(guild, member)
|
||||
before_n, _ = await self._find_last_nick_change(guild, member)
|
||||
now_ts = int(time.time())
|
||||
|
||||
# Compose review text
|
||||
title = "📝 **Nickname Verification Request**"
|
||||
who = f"User: {member.mention} (`{member.id}`)"
|
||||
change = f"Claimed {_ts_rel(now_ts)}"
|
||||
@ -110,7 +155,6 @@ class NickNudgeCog(commands.Cog):
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Persist review mapping
|
||||
self.bot.data_manager.add('nick_reviews', {
|
||||
'message_id': int(msg.id),
|
||||
'guild_id': int(guild.id),
|
||||
@ -122,23 +166,21 @@ class NickNudgeCog(commands.Cog):
|
||||
'ts': now_ts
|
||||
})
|
||||
|
||||
# Log to modlog
|
||||
await self._modlog(guild, f"🔎 Nickname review opened for {member.mention} — {method} — {_ts_rel(now_ts)}.")
|
||||
|
||||
# ---------- DM nudge loop (unchanged) ----------
|
||||
# ---------- DM nudge loop ----------
|
||||
|
||||
async def _nudge_loop(self):
|
||||
await self.bot.wait_until_ready()
|
||||
while not self.bot.is_closed():
|
||||
try:
|
||||
now = time.time()
|
||||
now_t = time.time()
|
||||
for guild in self.bot.guilds:
|
||||
for member in guild.members:
|
||||
if member.bot or not member.joined_at:
|
||||
continue
|
||||
if (now - member.joined_at.timestamp()) < 24*3600:
|
||||
if (now_t - member.joined_at.timestamp()) < 24 * 3600:
|
||||
continue
|
||||
# If they already have a server nick OR already claimed/verified, skip nudging
|
||||
dm = self.bot.data_manager
|
||||
if (member.nick and member.nick.strip()):
|
||||
continue
|
||||
@ -158,7 +200,7 @@ class NickNudgeCog(commands.Cog):
|
||||
'message_id': int(msg.id),
|
||||
'user_id': int(member.id),
|
||||
'guild_id': int(guild.id),
|
||||
'ts': now
|
||||
'ts': now_t
|
||||
})
|
||||
self.bot.data_manager.add('nick_nudged', int(member.id))
|
||||
await self._modlog(guild, f"📨 Sent nickname nudge to {member.mention}")
|
||||
@ -166,10 +208,52 @@ class NickNudgeCog(commands.Cog):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1800) # every 30 minutes
|
||||
await asyncio.sleep(1800)
|
||||
|
||||
# ---------- listeners ----------
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
||||
"""Revoke verification if a verified user changes their nick to anything."""
|
||||
if before.bot or before.guild != after.guild:
|
||||
return
|
||||
if before.nick == after.nick:
|
||||
return
|
||||
|
||||
dm = self.bot.data_manager
|
||||
# Only act if the user is currently verified
|
||||
if before.id in dm.get('nick_verified'):
|
||||
dm.remove('nick_verified', lambda x: x == before.id)
|
||||
dm.remove('nick_claim_pending', lambda x: x == before.id)
|
||||
try:
|
||||
dm.remove('nick_verified_name', lambda r: r.get('guild_id') == before.guild.id and r.get('user_id') == before.id)
|
||||
except Exception:
|
||||
pass
|
||||
dm.add('nick_verified_name', {
|
||||
'guild_id': int(before.guild.id),
|
||||
'user_id': int(before.id),
|
||||
'nick': after.nick if after.nick else None,
|
||||
'ts': int(time.time())
|
||||
})
|
||||
|
||||
try:
|
||||
await self._modlog(after.guild, f"⚠️ {after.mention} changed nickname; **verification revoked**. They must re-claim for a new review.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rr = self.bot.get_cog('ReactionRoleCog')
|
||||
if rr:
|
||||
try:
|
||||
await rr.maybe_apply_full_access(after)
|
||||
except Exception:
|
||||
pass
|
||||
cards = self.bot.get_cog('UserCardsCog')
|
||||
if cards:
|
||||
try:
|
||||
await cards.refresh_card(after)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
|
||||
# 1) Handle DM nudge confirmations (user reacts with an accept in DM)
|
||||
@ -181,26 +265,14 @@ class NickNudgeCog(commands.Cog):
|
||||
member = guild.get_member(entry['user_id']) if guild else None
|
||||
if not member:
|
||||
return
|
||||
# Treat as a claim: mark pending (idempotent) and open review only on first transition
|
||||
dm = self.bot.data_manager
|
||||
if member.id not in dm.get('agreed_nickname'):
|
||||
dm.add('agreed_nickname', int(member.id))
|
||||
dm.remove('nick_verified', lambda x: x == member.id)
|
||||
newly_pending = False
|
||||
if member.id not in dm.get('nick_claim_pending'):
|
||||
dm.add('nick_claim_pending', int(member.id))
|
||||
newly_pending = True
|
||||
|
||||
if newly_pending:
|
||||
try:
|
||||
await self.start_nick_review(guild, member, source="nick_same")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await self.ensure_pending_and_maybe_open(guild, member, source="nick_same")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clean map entry
|
||||
self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id)
|
||||
|
||||
# Refresh card and maybe full access (pending does NOT block full access)
|
||||
rr = self.bot.get_cog('ReactionRoleCog')
|
||||
if rr:
|
||||
try:
|
||||
@ -217,16 +289,14 @@ class NickNudgeCog(commands.Cog):
|
||||
|
||||
# 2) Handle moderator review reactions in mod channel
|
||||
if payload.guild_id and str(payload.emoji) in (CHECK, CROSS) and payload.user_id != self.bot.user.id:
|
||||
if payload.channel_id != self.mod_channel_id:
|
||||
if payload.channel_id != self.mod_channel_id or not self.mod_channel_id:
|
||||
return
|
||||
guild = self.bot.get_guild(payload.guild_id)
|
||||
if not guild:
|
||||
return
|
||||
# Only moderators can act
|
||||
if not is_moderator_userid(guild, payload.user_id, self.bot):
|
||||
return
|
||||
|
||||
# Is this a review message?
|
||||
reviews = self.bot.data_manager.get('nick_reviews')
|
||||
review = next((r for r in reviews if r.get('message_id') == payload.message_id and r.get('guild_id') == guild.id), None)
|
||||
if not review or review.get('status') != 'pending':
|
||||
@ -234,11 +304,9 @@ class NickNudgeCog(commands.Cog):
|
||||
|
||||
member = guild.get_member(int(review['user_id']))
|
||||
if not member:
|
||||
# mark closed missing
|
||||
self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status':'closed_missing'}), r)[1])
|
||||
self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'closed_missing'}), r)[1])
|
||||
return
|
||||
|
||||
# Fetch and edit the review message content (best-effort)
|
||||
try:
|
||||
ch = self.bot.get_channel(payload.channel_id)
|
||||
msg = await ch.fetch_message(payload.message_id)
|
||||
@ -250,17 +318,25 @@ class NickNudgeCog(commands.Cog):
|
||||
approver = f"<@{payload.user_id}>"
|
||||
|
||||
if str(payload.emoji) == CHECK:
|
||||
# Approve: mark verified, clear pending, ensure agreed flag set
|
||||
if member.id not in dm.get('agreed_nickname'):
|
||||
dm.add('agreed_nickname', int(member.id))
|
||||
dm.remove('nick_claim_pending', lambda x: x == member.id)
|
||||
if member.id not in dm.get('nick_verified'):
|
||||
dm.add('nick_verified', int(member.id))
|
||||
|
||||
# Update review record
|
||||
try:
|
||||
dm.remove('nick_verified_name', lambda r: r.get('guild_id') == guild.id and r.get('user_id') == member.id)
|
||||
except Exception:
|
||||
pass
|
||||
dm.add('nick_verified_name', {
|
||||
'guild_id': int(guild.id),
|
||||
'user_id': int(member.id),
|
||||
'nick': member.nick if member.nick else None,
|
||||
'ts': now_ts
|
||||
})
|
||||
|
||||
dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'approved', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
|
||||
|
||||
# Edit the review message
|
||||
if msg:
|
||||
try:
|
||||
await msg.clear_reactions()
|
||||
@ -271,12 +347,8 @@ class NickNudgeCog(commands.Cog):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Modlog
|
||||
await self._modlog(guild,
|
||||
f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}."
|
||||
)
|
||||
await self._modlog(guild, f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}.")
|
||||
|
||||
# Refresh roles / card
|
||||
rr = self.bot.get_cog('ReactionRoleCog')
|
||||
if rr:
|
||||
try:
|
||||
@ -291,12 +363,10 @@ class NickNudgeCog(commands.Cog):
|
||||
pass
|
||||
|
||||
else:
|
||||
# Reject: clear all nickname flags; Full Access should be revoked by maybe_apply_full_access
|
||||
dm.remove('agreed_nickname', lambda x: x == member.id)
|
||||
dm.remove('nick_claim_pending', lambda x: x == member.id)
|
||||
dm.remove('nick_verified', lambda x: x == member.id)
|
||||
|
||||
# Update review record
|
||||
dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'rejected', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
|
||||
|
||||
if msg:
|
||||
@ -309,11 +379,8 @@ class NickNudgeCog(commands.Cog):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await self._modlog(guild,
|
||||
f"❌ Nickname **rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}."
|
||||
)
|
||||
await self._modlog(guild, f"❌ Nickname **rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}.")
|
||||
|
||||
# Refresh roles / card
|
||||
rr = self.bot.get_cog('ReactionRoleCog')
|
||||
if rr:
|
||||
try:
|
||||
@ -327,5 +394,140 @@ class NickNudgeCog(commands.Cog):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------- Mod commands to manipulate nickname reviews ----------
|
||||
|
||||
@app_commands.command(name="clear_nick_reviews", description="[MOD] Delete all PENDING nickname review records for this server.")
|
||||
async def clear_nick_reviews(self, interaction: discord.Interaction):
|
||||
"""Moderator-only. Clears all 'pending' entries in data_manager['nick_reviews'] for this guild."""
|
||||
# Must be used in a guild
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
|
||||
# Moderator permission check (your existing gate)
|
||||
if not await require_mod_interaction(interaction):
|
||||
return # require_mod_interaction already responded
|
||||
|
||||
dm = self.bot.data_manager
|
||||
|
||||
# Count pending records for this guild
|
||||
pending = [
|
||||
r for r in dm.get('nick_reviews')
|
||||
if r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending'
|
||||
]
|
||||
count = len(pending)
|
||||
|
||||
# Remove pending records
|
||||
if count:
|
||||
dm.remove(
|
||||
'nick_reviews',
|
||||
lambda r: r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending'
|
||||
)
|
||||
|
||||
# Modlog + ephemeral confirmation
|
||||
try:
|
||||
await self._modlog(
|
||||
interaction.guild,
|
||||
f"🧹 {interaction.user.mention} cleared **{count}** pending nickname review(s)."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"Cleared **{count}** pending nickname review{'s' if count != 1 else ''}.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(name="recreate_nick_reviews", description="[MOD] Scan and recreate any missing pending nickname reviews for this server.")
|
||||
async def recreate_nick_reviews(self, interaction: discord.Interaction):
|
||||
"""Moderator-only bulk fixer for 'grey checkmark' users (claimed but no pending review)."""
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
if not await require_mod_interaction(interaction):
|
||||
return # already replied
|
||||
|
||||
dm = self.bot.data_manager
|
||||
guild = interaction.guild
|
||||
|
||||
agreed = set(dm.get('agreed_nickname'))
|
||||
verified = set(dm.get('nick_verified'))
|
||||
# Build a quick lookup of existing pending reviews
|
||||
pending_reviews = {
|
||||
(r.get('guild_id'), r.get('user_id'))
|
||||
for r in dm.get('nick_reviews')
|
||||
if r.get('status') == 'pending'
|
||||
}
|
||||
|
||||
to_fix = []
|
||||
for uid in agreed:
|
||||
# Needs a review if not verified and no pending review exists
|
||||
if (guild.id, uid) not in pending_reviews and uid not in verified:
|
||||
m = guild.get_member(uid)
|
||||
if m and not m.bot:
|
||||
to_fix.append(m)
|
||||
|
||||
fixed = 0
|
||||
skipped = 0
|
||||
for member in to_fix:
|
||||
try:
|
||||
# Clear stale pending so the atomic method will transition and open a new one
|
||||
dm.remove('nick_claim_pending', lambda x, _uid=member.id: x == _uid)
|
||||
await self.ensure_pending_and_maybe_open(guild, member, source="recreate")
|
||||
fixed += 1
|
||||
except Exception:
|
||||
skipped += 1
|
||||
|
||||
try:
|
||||
await self._modlog(guild, f"🛠️ {interaction.user.mention} recreated **{fixed}** nickname review(s); skipped **{skipped}**.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"Recreated **{fixed}** review(s); skipped **{skipped}**.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(name="recreate_nick_review", description="[MOD] Recreate a missing pending nickname review for one user.")
|
||||
@app_commands.describe(user="Member to recreate review for")
|
||||
async def recreate_nick_review(self, interaction: discord.Interaction, user: discord.Member):
|
||||
"""Moderator-only single-user fixer."""
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
if not await require_mod_interaction(interaction):
|
||||
return # already replied
|
||||
|
||||
guild = interaction.guild
|
||||
dm = self.bot.data_manager
|
||||
|
||||
# If already verified, nothing to do
|
||||
if user.id in dm.get('nick_verified'):
|
||||
return await interaction.response.send_message("User is already verified — no review needed.", ephemeral=True)
|
||||
|
||||
# If a pending review already exists, nothing to do
|
||||
has_pending = any(
|
||||
r.get('guild_id') == guild.id and r.get('user_id') == user.id and r.get('status') == 'pending'
|
||||
for r in dm.get('nick_reviews')
|
||||
)
|
||||
if has_pending:
|
||||
return await interaction.response.send_message("A pending review already exists for this user.", ephemeral=True)
|
||||
|
||||
# If they never agreed/claimed, mark claim now so the state is consistent
|
||||
if user.id not in dm.get('agreed_nickname'):
|
||||
dm.add('agreed_nickname', int(user.id))
|
||||
|
||||
# Clear stale pending flag, then open atomically
|
||||
dm.remove('nick_claim_pending', lambda x: x == user.id)
|
||||
|
||||
try:
|
||||
await self.ensure_pending_and_maybe_open(guild, user, source="recreate")
|
||||
except Exception:
|
||||
return await interaction.response.send_message("Failed to create the review (see logs).", ephemeral=True)
|
||||
|
||||
try:
|
||||
await self._modlog(guild, f"🛠️ {interaction.user.mention} recreated a nickname review for {user.mention}.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await interaction.response.send_message("Recreated the nickname review for that user.", ephemeral=True)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(NickNudgeCog(bot))
|
||||
|
@ -2,25 +2,24 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
from datetime import datetime
|
||||
from mod_perms import require_mod_ctx # use your configured moderator roles
|
||||
from mod_perms import require_mod_ctx, require_mod_interaction
|
||||
from modules.common.settings import cfg # ENV-first config helper
|
||||
|
||||
|
||||
class PirateCardsCog(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
cfg = bot.config['DEFAULT']
|
||||
self.pirates_channel_id = int(cfg['pirates_list_channel_id'])
|
||||
self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0
|
||||
r = cfg(bot)
|
||||
|
||||
# thresholds / samples (optional, with defaults)
|
||||
try:
|
||||
self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
|
||||
except Exception:
|
||||
self.group_threshold = 3
|
||||
try:
|
||||
self.min_samples = int(cfg.get('threat_min_samples_for_stats', '3'))
|
||||
except Exception:
|
||||
self.min_samples = 3
|
||||
# IDs / config (ENV -> optional INI fallback)
|
||||
self.pirates_channel_id = r.int('pirates_list_channel_id', 0)
|
||||
self.modlog_channel_id = r.int('modlog_channel_id', 0)
|
||||
|
||||
# thresholds / samples (with defaults)
|
||||
self.group_threshold = r.int('threat_group_threshold', 3)
|
||||
self.min_samples = r.int('threat_min_samples_for_stats', 3)
|
||||
|
||||
# safe posting (don’t ping)
|
||||
self._no_mentions = discord.AllowedMentions.none()
|
||||
@ -97,7 +96,7 @@ class PirateCardsCog(commands.Cog):
|
||||
async def _build_embed(self, pirate: dict) -> discord.Embed:
|
||||
encs = self._encounters_for(pirate)
|
||||
total = len(encs)
|
||||
# guard numeric fields
|
||||
|
||||
def _i(v, d=0):
|
||||
try:
|
||||
return int(v)
|
||||
@ -198,29 +197,28 @@ class PirateCardsCog(commands.Cog):
|
||||
await self.refresh_card_for_account(guild, new_account)
|
||||
|
||||
# -------- command (mod-gated via require_mod_ctx) --------
|
||||
@commands.hybrid_command(name="pirate_cards_rebuild", description="Rebuild pirate cards for all known pirates")
|
||||
async def pirate_cards_rebuild(self, ctx: commands.Context):
|
||||
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
|
||||
@app_commands.command(name="pirate_cards_rebuild", description="[MOD] Rebuild pirate cards for all known pirates")
|
||||
async def pirate_cards_rebuild(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
|
||||
return
|
||||
if not ctx.guild:
|
||||
return await ctx.reply("Use this in a server.", ephemeral=True)
|
||||
|
||||
async with self._lock_for(ctx.guild.id):
|
||||
guild = ctx.guild
|
||||
ch = guild.get_channel(self.pirates_channel_id)
|
||||
if not ch:
|
||||
return await ctx.reply("Configured pirates_list_channel_id not found.", ephemeral=True)
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
guild = interaction.guild
|
||||
ch = guild.get_channel(self.pirates_channel_id)
|
||||
if not ch:
|
||||
return await interaction.followup.send("Configured pirates_list_channel_id not found.", ephemeral=True)
|
||||
|
||||
count = 0
|
||||
for p in self.bot.data_manager.get('pirates'):
|
||||
try:
|
||||
await self.refresh_card_for_account(guild, p.get('account_name', ''))
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
count = 0
|
||||
for p in self.bot.data_manager.get('pirates'):
|
||||
try:
|
||||
await self.refresh_card_for_account(guild, p.get('account_name', ''))
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
|
||||
await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash)
|
||||
await interaction.followup.send(f"Rebuilt/updated {count} pirate cards.", ephemeral=True)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(PirateCardsCog(bot))
|
||||
|
@ -1,9 +1,12 @@
|
||||
# modules/pirate_report/pirate_report.py
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
from modules.common.settings import cfg
|
||||
|
||||
from mod_perms import (
|
||||
is_moderator_member,
|
||||
@ -19,12 +22,20 @@ CROSS = '❌'
|
||||
|
||||
_ACCT_RE = re.compile(r'.+#\d{5}$')
|
||||
|
||||
DISCORD_MEDIA_HOSTS = {"cdn.discordapp.com", "media.discordapp.net"}
|
||||
MEDIA_EXTS_IMAGE = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
MEDIA_EXTS_VIDEO = {".mp4", ".webm", ".mov"}
|
||||
MEDIA_EXTS_ALL = MEDIA_EXTS_IMAGE | MEDIA_EXTS_VIDEO
|
||||
|
||||
|
||||
def _acct_ok(s: str) -> bool:
|
||||
return bool(_ACCT_RE.fullmatch(s.strip()))
|
||||
|
||||
|
||||
def _now_utc_str() -> str:
|
||||
return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
||||
|
||||
|
||||
def _parse_bool(s: str) -> bool:
|
||||
v = s.strip().lower()
|
||||
if v in ('y', 'yes', 'true', 't', '1'):
|
||||
@ -33,6 +44,81 @@ def _parse_bool(s: str) -> bool:
|
||||
return False
|
||||
raise ValueError("Please enter yes or no")
|
||||
|
||||
|
||||
def _classify_discord_media(url: str):
|
||||
"""
|
||||
Return ('image'|'video', normalized_url) if valid Discord CDN media; else (None, reason).
|
||||
We only accept direct CDN links so media can render inline without leaving the channel.
|
||||
"""
|
||||
try:
|
||||
u = url.strip()
|
||||
if not u:
|
||||
return (None, "Empty URL.")
|
||||
pr = urlparse(u)
|
||||
if pr.scheme not in ("http", "https"):
|
||||
return (None, "URL must start with http:// or https://")
|
||||
host = pr.netloc.lower()
|
||||
if host not in DISCORD_MEDIA_HOSTS:
|
||||
return (None, "URL must be a **Discord media** link (cdn.discordapp.com or media.discordapp.net).")
|
||||
path = pr.path or ""
|
||||
dot = path.rfind(".")
|
||||
if dot == -1:
|
||||
return (None, "URL must end with a known media file extension.")
|
||||
ext = path[dot:].lower()
|
||||
if ext not in MEDIA_EXTS_ALL:
|
||||
return (None, f"Unsupported media type `{ext}`. Allowed: images {sorted(MEDIA_EXTS_IMAGE)}, videos {sorted(MEDIA_EXTS_VIDEO)}.")
|
||||
kind = "image" if ext in MEDIA_EXTS_IMAGE else "video"
|
||||
return (kind, u)
|
||||
except Exception:
|
||||
return (None, "Invalid URL format.")
|
||||
|
||||
|
||||
def _jump_url(guild_id: int, channel_id: int, message_id: int) -> str:
|
||||
return f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}"
|
||||
|
||||
|
||||
def _make_report_embed(title: str, color: discord.Color, report_dict: dict, include_status: bool = False):
|
||||
e = discord.Embed(title=title, color=color, timestamp=datetime.utcnow())
|
||||
e.add_field(name="Character", value=report_dict['character_name'], inline=False)
|
||||
e.add_field(name="Account", value=report_dict['account_name'], inline=False)
|
||||
e.add_field(name="Submitted by", value=f"<@{report_dict['submitter_id']}>", inline=False)
|
||||
|
||||
proof_url = (report_dict.get('proof_url') or "").strip()
|
||||
proof_type = (report_dict.get('proof_type') or "").strip()
|
||||
|
||||
if include_status:
|
||||
e.add_field(
|
||||
name="Status",
|
||||
value=report_dict.get('status_line', 'Pending'),
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Show proof inline if it's an image; for videos we'll keep the URL in message content.
|
||||
if proof_url and proof_type == 'image':
|
||||
e.set_image(url=proof_url)
|
||||
|
||||
# Add a compact proof field for quick visibility (always safe to include)
|
||||
e.add_field(
|
||||
name="Proof",
|
||||
value=proof_url if proof_url else "_No proof provided — strongly encouraged._",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# A small hint footer
|
||||
e.set_footer(text="Attach proof when possible to speed up moderation.")
|
||||
return e
|
||||
|
||||
|
||||
# --------------------- Views ----------------------
|
||||
|
||||
class ReportJumpView(discord.ui.View):
|
||||
"""Simple link button to jump to the user's original ack message."""
|
||||
def __init__(self, url: str):
|
||||
super().__init__(timeout=None)
|
||||
if url:
|
||||
self.add_item(discord.ui.Button(label="Jump to message", style=discord.ButtonStyle.link, url=url))
|
||||
|
||||
|
||||
# --------------------- Modals ----------------------
|
||||
|
||||
class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
|
||||
@ -52,9 +138,16 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
|
||||
max_length=64,
|
||||
required=True
|
||||
)
|
||||
self.proof_url = discord.ui.TextInput(
|
||||
label="Proof (Discord media URL — optional)",
|
||||
placeholder="Direct Discord CDN link to image/video (highly encouraged, but optional)",
|
||||
required=False,
|
||||
max_length=300
|
||||
)
|
||||
|
||||
self.add_item(self.character_name)
|
||||
self.add_item(self.account_name)
|
||||
self.add_item(self.proof_url)
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
@ -62,6 +155,7 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
|
||||
|
||||
char = self.character_name.value.strip()
|
||||
acct = self.account_name.value.strip()
|
||||
proof_raw = (self.proof_url.value or "").strip()
|
||||
|
||||
if not _acct_ok(acct):
|
||||
return await interaction.response.send_message(
|
||||
@ -69,6 +163,14 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
proof_type = ""
|
||||
proof_val = ""
|
||||
if proof_raw:
|
||||
pt, pv = _classify_discord_media(proof_raw)
|
||||
if pt is None:
|
||||
return await interaction.response.send_message(f"❌ Invalid proof link: {pv}", ephemeral=True)
|
||||
proof_type, proof_val = pt, pv # valid
|
||||
|
||||
dm = self.cog.bot.data_manager
|
||||
char_l = char.lower()
|
||||
acct_l = acct.lower()
|
||||
@ -95,18 +197,33 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
|
||||
print("[pirate_report] ack send failed:", repr(e))
|
||||
ack = None
|
||||
|
||||
# Send to mod channel with ✅/❌
|
||||
# Send to mod channel with ✅/❌, including inline media if present
|
||||
mod_ch = interaction.guild.get_channel(self.cog.mod_channel)
|
||||
if not mod_ch:
|
||||
return await interaction.response.send_message("❌ Mod channel not configured.", ephemeral=True)
|
||||
|
||||
report_payload = {
|
||||
'character_name': char,
|
||||
'account_name': acct,
|
||||
'submitter_id': interaction.user.id,
|
||||
'proof_url': proof_val,
|
||||
'proof_type': proof_type,
|
||||
}
|
||||
|
||||
# Build jump button (if ack exists)
|
||||
view = None
|
||||
if ack:
|
||||
try:
|
||||
url = _jump_url(interaction.guild.id, interaction.channel.id, ack.id)
|
||||
view = ReportJumpView(url)
|
||||
except Exception:
|
||||
view = None
|
||||
|
||||
try:
|
||||
mod_msg = await mod_ch.send(
|
||||
f"🚩 **Pirate Report**\n"
|
||||
f"**Character:** {char}\n"
|
||||
f"**Account:** {acct}\n"
|
||||
f"**Submitted by:** {interaction.user.mention}"
|
||||
)
|
||||
embed = _make_report_embed("🚩 Pirate Report", discord.Color.orange(), report_payload, include_status=False)
|
||||
# Video must be in message content for inline player; images live in the embed
|
||||
content = proof_val if proof_type == 'video' else None
|
||||
mod_msg = await mod_ch.send(content=content, embed=embed, view=view)
|
||||
await mod_msg.add_reaction(CHECK)
|
||||
await mod_msg.add_reaction(CROSS)
|
||||
except Exception as e:
|
||||
@ -122,11 +239,14 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
|
||||
'origin_channel_id': interaction.channel.id if interaction.channel else 0,
|
||||
'ack_message_id': ack.id if ack else 0,
|
||||
'status': 'pending',
|
||||
'ts': now
|
||||
'ts': now,
|
||||
'proof_url': proof_val,
|
||||
'proof_type': proof_type,
|
||||
})
|
||||
|
||||
await interaction.response.send_message("✅ Report submitted to moderators.", ephemeral=True)
|
||||
|
||||
|
||||
class EditPirateModal(discord.ui.Modal, title="Edit Pirate Entry"):
|
||||
def __init__(self, cog: "PirateReportCog"):
|
||||
super().__init__()
|
||||
@ -201,6 +321,7 @@ class EditPirateModal(discord.ui.Modal, title="Edit Pirate Entry"):
|
||||
await interaction.response.send_message("✅ Pirate updated.", ephemeral=True)
|
||||
await self.cog._refresh_pirates_list(interaction.guild)
|
||||
|
||||
|
||||
class EncounterModal(discord.ui.Modal, title="Log Pirate Encounter"):
|
||||
def __init__(self, cog: "PirateReportCog"):
|
||||
super().__init__()
|
||||
@ -336,25 +457,31 @@ class EncounterModal(discord.ui.Modal, title="Log Pirate Encounter"):
|
||||
|
||||
await self.cog._refresh_pirates_list(interaction.guild)
|
||||
|
||||
|
||||
# -------------- Cog: commands + listeners ---------------
|
||||
|
||||
class PirateReportCog(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
cfg = bot.config['DEFAULT']
|
||||
self.mod_channel = int(cfg['mod_channel_id'])
|
||||
self.modlog_channel_id = int(cfg['modlog_channel_id'])
|
||||
r = cfg(bot)
|
||||
|
||||
# Optional threat weights (normalized elsewhere if you added them)
|
||||
try:
|
||||
self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
|
||||
except Exception:
|
||||
self.group_threshold = 3
|
||||
# Defaults if not already present in your earlier version:
|
||||
self.w_kill = float(cfg.get('threat_w_kill', '0.35'))
|
||||
self.w_destruction = float(cfg.get('threat_w_destruction', '0.30'))
|
||||
self.w_group = float(cfg.get('threat_w_group', '0.20'))
|
||||
self.w_skill = float(cfg.get('threat_w_skill', '0.15'))
|
||||
# Channels
|
||||
self.mod_channel = r.int('mod_channel_id', 0)
|
||||
self.modlog_channel_id = r.int('modlog_channel_id', 0)
|
||||
|
||||
# Threat config
|
||||
self.group_threshold = r.int('threat_group_threshold', 3)
|
||||
|
||||
def _f(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(r.get(key, str(default)))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
self.w_kill = _f('threat_w_kill', 0.35)
|
||||
self.w_destruction = _f('threat_w_destruction', 0.30)
|
||||
self.w_group = _f('threat_w_group', 0.20)
|
||||
self.w_skill = _f('threat_w_skill', 0.15)
|
||||
|
||||
async def _refresh_pirates_list(self, guild: discord.Guild):
|
||||
plist = self.bot.get_cog('PiratesListCog')
|
||||
@ -362,6 +489,8 @@ class PirateReportCog(commands.Cog):
|
||||
await plist.refresh_list(guild)
|
||||
|
||||
async def _modlog(self, guild: discord.Guild, content: str):
|
||||
if not self.modlog_channel_id:
|
||||
return
|
||||
ch = guild.get_channel(self.modlog_channel_id)
|
||||
if ch:
|
||||
try:
|
||||
@ -389,18 +518,23 @@ class PirateReportCog(commands.Cog):
|
||||
return (matches[0], None)
|
||||
|
||||
# Remove pirate (mod-only)
|
||||
@commands.hybrid_command(name='remove_pirate', description='Remove an approved pirate entry')
|
||||
async def remove_pirate(self, ctx, account_name: str):
|
||||
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
|
||||
@app_commands.command(name="remove_pirate", description="[MOD] Remove an approved pirate entry")
|
||||
@app_commands.describe(account_name="Account name to remove")
|
||||
async def remove_pirate(self, interaction: discord.Interaction, account_name: str):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
|
||||
return
|
||||
|
||||
acct_lower = account_name.strip().lower()
|
||||
dm = self.bot.data_manager
|
||||
if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')):
|
||||
return await ctx.reply("Pirate not found.")
|
||||
return await interaction.response.send_message("Pirate not found.", ephemeral=True)
|
||||
|
||||
dm.remove('pirates', lambda p: p['account_name'].lower() == acct_lower)
|
||||
await self._modlog(ctx.guild, f"🗑️ Removed pirate {account_name} by {ctx.author.mention}")
|
||||
await self._refresh_pirates_list(ctx.guild)
|
||||
await ctx.reply("Removed.", ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None)
|
||||
await self._modlog(interaction.guild, f"🗑️ Removed pirate {account_name} by {interaction.user.mention}")
|
||||
await self._refresh_pirates_list(interaction.guild)
|
||||
await interaction.response.send_message("Removed.", ephemeral=True)
|
||||
|
||||
# Modal launchers
|
||||
@app_commands.command(name="report", description="Submit a pirate report for moderator review (opens a form)")
|
||||
@ -409,7 +543,7 @@ class PirateReportCog(commands.Cog):
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
await interaction.response.send_modal(ReportModal(self))
|
||||
|
||||
@app_commands.command(name="edit_pirate", description="Edit a pirate entry (opens a form)")
|
||||
@app_commands.command(name="edit_pirate", description="[MOD] Edit a pirate entry (opens a form)")
|
||||
async def edit_pirate(self, interaction: discord.Interaction):
|
||||
if not await require_mod_interaction(interaction):
|
||||
return
|
||||
@ -422,10 +556,15 @@ class PirateReportCog(commands.Cog):
|
||||
await interaction.response.send_modal(EncounterModal(self))
|
||||
|
||||
# ---- Migration: convert encounter identifiers to accounts (mod-only) ----
|
||||
@commands.hybrid_command(name='encounters_migrate_ids', description='Migrate encounter identifiers to account names')
|
||||
async def encounters_migrate_ids(self, ctx: commands.Context):
|
||||
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
|
||||
@app_commands.command(name="encounters_migrate_ids", description="[MOD] Migrate encounter identifiers to account names")
|
||||
async def encounters_migrate_ids(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
dm = self.bot.data_manager
|
||||
pirates = dm.get('pirates')
|
||||
by_char = {}
|
||||
@ -433,10 +572,7 @@ class PirateReportCog(commands.Cog):
|
||||
by_char.setdefault(p['character_name'].lower(), []).append(p)
|
||||
by_acct = {p['account_name'].lower(): p for p in pirates}
|
||||
|
||||
changed = 0
|
||||
ambiguous = 0
|
||||
missing = 0
|
||||
already = 0
|
||||
changed = ambiguous = missing = already = 0
|
||||
|
||||
for e in dm.get('encounters'):
|
||||
ident = e.get('identifier', '')
|
||||
@ -454,26 +590,27 @@ class PirateReportCog(commands.Cog):
|
||||
ambiguous += 1
|
||||
continue
|
||||
acct = matches[0]['account_name']
|
||||
# update this one entry atomically
|
||||
|
||||
def pred(x, ts=e['timestamp'], rid=e['reporter_id'], ident_old=ident):
|
||||
return x.get('timestamp') == ts and x.get('reporter_id') == rid and x.get('identifier') == ident_old
|
||||
def upd(x, acct_new=acct):
|
||||
x['identifier'] = acct_new
|
||||
return x
|
||||
|
||||
ok = dm.update('encounters', pred, upd)
|
||||
if ok:
|
||||
changed += 1
|
||||
|
||||
await ctx.reply(
|
||||
f"Migration complete.\n"
|
||||
await interaction.followup.send(
|
||||
"Migration complete.\n"
|
||||
f"- Updated to accounts: **{changed}**\n"
|
||||
f"- Already accounts: **{already}**\n"
|
||||
f"- Ambiguous character names (skipped): **{ambiguous}**\n"
|
||||
f"- Not found in pirates list (skipped): **{missing}**",
|
||||
ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
await self._refresh_pirates_list(ctx.guild)
|
||||
await self._refresh_pirates_list(interaction.guild)
|
||||
|
||||
# Moderator reaction handling (atomic claim)
|
||||
@commands.Cog.listener()
|
||||
@ -516,20 +653,38 @@ class PirateReportCog(commands.Cog):
|
||||
guild = channel.guild
|
||||
stamp = _now_utc_str()
|
||||
|
||||
header_emoji = CHECK if approved else CROSS
|
||||
new_content = (
|
||||
f"{header_emoji} **Pirate Report**\n"
|
||||
f"**Character:** {report['character_name']}\n"
|
||||
f"**Account:** {report['account_name']}\n"
|
||||
f"**Submitted by:** <@{report['submitter_id']}>\n\n"
|
||||
f"**Status:** {'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}"
|
||||
)
|
||||
# Build new embed + content (keep proof visible if present)
|
||||
status_text = f"{'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}"
|
||||
report_view = {
|
||||
'character_name': report['character_name'],
|
||||
'account_name': report['account_name'],
|
||||
'submitter_id': report['submitter_id'],
|
||||
'proof_url': report.get('proof_url', ''),
|
||||
'proof_type': report.get('proof_type', ''),
|
||||
'status_line': status_text,
|
||||
}
|
||||
color = discord.Color.green() if approved else discord.Color.red()
|
||||
title = "✅ Pirate Report — Approved" if approved else "❌ Pirate Report — Rejected"
|
||||
new_embed = _make_report_embed(title, color, report_view, include_status=True)
|
||||
|
||||
# For videos, ensure the URL stays in message content so the inline player remains visible.
|
||||
new_content = report_view['proof_url'] if report_view.get('proof_type') == 'video' else None
|
||||
|
||||
# Always (re)attach jump button if we have the ack info
|
||||
view = None
|
||||
try:
|
||||
if report.get('origin_channel_id') and report.get('ack_message_id'):
|
||||
url = _jump_url(guild.id, report['origin_channel_id'], report['ack_message_id'])
|
||||
view = ReportJumpView(url)
|
||||
except Exception:
|
||||
view = None
|
||||
|
||||
try:
|
||||
await msg.clear_reactions()
|
||||
except Exception as e:
|
||||
print("[pirate_report] clear reactions failed:", repr(e))
|
||||
try:
|
||||
await msg.edit(content=new_content)
|
||||
await msg.edit(content=new_content, embed=new_embed, view=view)
|
||||
except Exception as e:
|
||||
print("[pirate_report] edit mod msg failed:", repr(e))
|
||||
|
||||
@ -559,21 +714,6 @@ class PirateReportCog(commands.Cog):
|
||||
|
||||
dm.remove('reports', lambda r: r.get('report_id') == msg.id)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
cog = PirateReportCog(bot)
|
||||
await bot.add_cog(cog)
|
||||
|
||||
try:
|
||||
home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
|
||||
except Exception:
|
||||
home_gid = 0
|
||||
|
||||
if home_gid:
|
||||
guild_obj = discord.Object(id=home_gid)
|
||||
bot.tree.add_command(cog.report, guild=guild_obj)
|
||||
bot.tree.add_command(cog.edit_pirate, guild=guild_obj)
|
||||
bot.tree.add_command(cog.encounter, guild=guild_obj)
|
||||
else:
|
||||
bot.tree.add_command(cog.report)
|
||||
bot.tree.add_command(cog.edit_pirate)
|
||||
bot.tree.add_command(cog.encounter)
|
||||
await bot.add_cog(PirateReportCog(bot))
|
||||
|
@ -2,7 +2,9 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from mod_perms import require_mod_ctx # ctx-aware mod gate
|
||||
from discord import app_commands
|
||||
from mod_perms import require_mod_ctx, require_mod_interaction
|
||||
from modules.common.settings import cfg as _cfg
|
||||
|
||||
|
||||
class PiratesListCog(commands.Cog):
|
||||
@ -16,29 +18,17 @@ class PiratesListCog(commands.Cog):
|
||||
Posts are chunked to stay <2000 chars and previous posts are deleted on refresh.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
cfg = bot.config["DEFAULT"]
|
||||
self.list_channel_id = int(cfg["pirates_list_channel_id"])
|
||||
try:
|
||||
self.group_threshold = int(cfg.get("threat_group_threshold", "3"))
|
||||
except Exception:
|
||||
self.group_threshold = 3
|
||||
try:
|
||||
self.min_samples = int(cfg.get("threat_min_samples_for_stats", "3"))
|
||||
except Exception:
|
||||
self.min_samples = 3
|
||||
|
||||
c = _cfg(bot)
|
||||
self.list_channel_id = c.int("pirates_list_channel_id")
|
||||
self.group_threshold = c.int("threat_group_threshold", 3)
|
||||
self.min_samples = c.int("threat_min_samples_for_stats", 3)
|
||||
|
||||
# serialize refreshes per guild
|
||||
self._locks = {}
|
||||
self._no_mentions = discord.AllowedMentions.none()
|
||||
|
||||
def _lock_for(self, guild_id: int):
|
||||
import asyncio
|
||||
self._locks.setdefault(guild_id, asyncio.Lock())
|
||||
return self._locks[guild_id]
|
||||
|
||||
# send settings: never ping on posted content
|
||||
self._locks: dict[int, asyncio.Lock] = {}
|
||||
# never ping on posted content
|
||||
self._no_mentions = discord.AllowedMentions.none()
|
||||
|
||||
# ----------------- utils -----------------
|
||||
@ -119,30 +109,19 @@ class PiratesListCog(commands.Cog):
|
||||
|
||||
async def refresh_list(self, guild: discord.Guild):
|
||||
"""Edit list messages in place; only send extra messages when we need more chunks (new pirates)."""
|
||||
# ---- serialize per guild ----
|
||||
lock = getattr(self, "_locks", {}).get(guild.id)
|
||||
if lock is None:
|
||||
# tiny fallback if you didn't add _lock_for()
|
||||
import asyncio as _asyncio
|
||||
if not hasattr(self, "_locks"):
|
||||
self._locks = {}
|
||||
self._locks[guild.id] = _asyncio.Lock()
|
||||
lock = self._locks[guild.id]
|
||||
|
||||
async with lock:
|
||||
async with self._lock_for(guild.id):
|
||||
channel = guild.get_channel(self.list_channel_id)
|
||||
if not channel:
|
||||
print("[pirates_list] list channel not found:", self.list_channel_id)
|
||||
return
|
||||
|
||||
dm = self.bot.data_manager
|
||||
allow = getattr(self, "_no_mentions", discord.AllowedMentions.none())
|
||||
allow = self._no_mentions
|
||||
|
||||
# ---- load & prune existing posts for this guild/channel ----
|
||||
records = [r for r in dm.get("pirates_list_posts")
|
||||
if r.get("guild_id") == guild.id and r.get("channel_id") == self.list_channel_id]
|
||||
if r.get("guild_id") == guild.id and r.get("channel_id") == self.list_channel_id]
|
||||
|
||||
# fetch messages (drop any that vanished)
|
||||
msgs, kept_records = [], []
|
||||
for r in records:
|
||||
try:
|
||||
@ -150,11 +129,8 @@ class PiratesListCog(commands.Cog):
|
||||
msgs.append(m)
|
||||
kept_records.append(r)
|
||||
except Exception:
|
||||
# prune dead record
|
||||
dm.remove("pirates_list_posts", lambda x, mid=r["message_id"]: x.get("message_id") == mid)
|
||||
|
||||
records = kept_records # only live ones, in stored order
|
||||
|
||||
# ---- build fresh, sorted contents ----
|
||||
pirates = sorted(
|
||||
dm.get("pirates"),
|
||||
@ -168,13 +144,11 @@ class PiratesListCog(commands.Cog):
|
||||
if not pirates:
|
||||
placeholder = "_No verified pirates yet._"
|
||||
if msgs:
|
||||
# edit first, delete the rest
|
||||
if msgs[0].content != placeholder:
|
||||
try:
|
||||
await msgs[0].edit(content=placeholder, allowed_mentions=allow)
|
||||
except Exception as e:
|
||||
print("[pirates_list] edit placeholder failed:", repr(e))
|
||||
# remove extra posts/records
|
||||
for extra in msgs[1:]:
|
||||
try:
|
||||
await extra.delete()
|
||||
@ -214,7 +188,7 @@ class PiratesListCog(commands.Cog):
|
||||
except Exception as e:
|
||||
print("[pirates_list] edit block failed:", repr(e))
|
||||
|
||||
# ---- if we need *more* messages (usually after adding a pirate), send them ----
|
||||
# ---- send additional messages if needed ----
|
||||
if len(chunks) > len(msgs):
|
||||
for i in range(len(msgs), len(chunks)):
|
||||
try:
|
||||
@ -227,7 +201,7 @@ class PiratesListCog(commands.Cog):
|
||||
except Exception as e:
|
||||
print("[pirates_list] send block failed:", repr(e))
|
||||
|
||||
# ---- if we need fewer messages (e.g., pirate removed), delete extras ----
|
||||
# ---- delete extras if fewer chunks now ----
|
||||
elif len(chunks) < len(msgs):
|
||||
extras = msgs[len(chunks):]
|
||||
for m in extras:
|
||||
@ -238,19 +212,17 @@ class PiratesListCog(commands.Cog):
|
||||
dm.remove("pirates_list_posts", lambda r, mid=m.id: r.get("message_id") == mid)
|
||||
|
||||
# Manual refresh command (hybrid: works as /pirates_list_refresh and !pirates_list_refresh)
|
||||
@commands.hybrid_command(name="pirates_list_refresh", description="Rebuild the compact pirates list")
|
||||
@commands.cooldown(1, 10, commands.BucketType.guild) # tiny anti-spam
|
||||
async def pirates_list_refresh(self, ctx: commands.Context):
|
||||
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
|
||||
@app_commands.command(name="pirates_list_refresh", description="[MOD] Rebuild the compact pirates list")
|
||||
@app_commands.checks.cooldown(1, 10) # guild-scope anti-spam analogue
|
||||
async def pirates_list_refresh(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
|
||||
return
|
||||
if not ctx.guild:
|
||||
return await ctx.reply("Use this in a server.", ephemeral=True)
|
||||
|
||||
await self.refresh_list(ctx.guild)
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
await self.refresh_list(interaction.guild)
|
||||
await interaction.followup.send("Pirates list refreshed.", ephemeral=True)
|
||||
|
||||
is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
|
||||
await ctx.reply("Pirates list refreshed.", ephemeral=is_slash)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(PiratesListCog(bot))
|
||||
|
0
modules/power/__init__.py
Normal file
150
modules/power/power.py
Normal file
@ -0,0 +1,150 @@
|
||||
# modules/power/power.py
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
|
||||
from modules.common.settings import cfg
|
||||
from mod_perms import require_mod_interaction
|
||||
|
||||
# ---------------- helpers ----------------
|
||||
|
||||
WEAK_REASONS = {
|
||||
"stuck", "idk", "dont know", "don't know", "unknown", "?",
|
||||
"lag", "restart", "restarting", "update", "updating", "bug",
|
||||
"crash", "crashed"
|
||||
}
|
||||
|
||||
def _now_utc_str() -> str:
|
||||
return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
||||
|
||||
def _reason_ok(s: str) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Enforce a descriptive reason:
|
||||
- >= 20 characters
|
||||
- >= 4 words
|
||||
- at least 3 words with length >= 3
|
||||
- reject trivial/weak phrases
|
||||
"""
|
||||
if not s:
|
||||
return False, "Reason is required."
|
||||
s = s.strip()
|
||||
if len(s) < 20:
|
||||
return False, "Please provide a more descriptive reason (≥ 20 characters)."
|
||||
words = re.findall(r"[A-Za-z0-9'-]+", s)
|
||||
if len(words) < 4:
|
||||
return False, "Please include at least 4 words."
|
||||
if sum(len(w) >= 3 for w in words) < 3:
|
||||
return False, "Add more detail—use at least three meaningful words (≥ 3 letters)."
|
||||
low = s.lower()
|
||||
if low in WEAK_REASONS:
|
||||
return False, "Reason is too vague. Please explain what happened."
|
||||
return True, None
|
||||
|
||||
async def _send_modlog(bot: commands.Bot, guild: discord.Guild, content: str):
|
||||
modlog_channel_id = cfg(bot).int('modlog_channel_id', 0)
|
||||
if not modlog_channel_id:
|
||||
print("[power] modlog_channel_id not configured; skipping modlog.")
|
||||
return
|
||||
ch = guild.get_channel(modlog_channel_id) or bot.get_channel(modlog_channel_id)
|
||||
if ch:
|
||||
try:
|
||||
await ch.send(content, allowed_mentions=discord.AllowedMentions.none())
|
||||
except Exception as e:
|
||||
print("[power] failed to send modlog:", repr(e))
|
||||
else:
|
||||
print(f"[power] channel id {modlog_channel_id} not found.")
|
||||
|
||||
def _current_version(bot: commands.Bot) -> str | None:
|
||||
"""Best-effort: read last detected version from boot_state."""
|
||||
dm = getattr(bot, "data_manager", None)
|
||||
if not dm:
|
||||
return None
|
||||
try:
|
||||
st = (dm.get('boot_state') or [{}])[-1] if dm.get('boot_state') else {}
|
||||
v = st.get('last_version')
|
||||
return v if v else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _graceful_restart(bot: commands.Bot, delay: float = 2.0):
|
||||
# small delay so modlog flushes out to Discord
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
await bot.close()
|
||||
except Exception as e:
|
||||
print("[power] bot.close() raised:", repr(e))
|
||||
# Force exit so container/wrapper restarts us
|
||||
os._exit(0) # noqa
|
||||
|
||||
# ---------------- Cog + slash group ----------------
|
||||
|
||||
class PowerActionsCog(commands.Cog):
|
||||
"""Administrative power actions (mod-only)."""
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
power = app_commands.Group(name="power", description="Administrative power actions (mod-only)")
|
||||
|
||||
@power.command(name="restart", description="[MOD] Restart the bot. Provide a descriptive reason.")
|
||||
@app_commands.describe(reason="Explain why a restart is necessary (be specific).")
|
||||
async def restart(self, interaction: discord.Interaction, reason: str):
|
||||
# Mods only
|
||||
if not await require_mod_interaction(interaction):
|
||||
return
|
||||
|
||||
ok, err = _reason_ok(reason)
|
||||
if not ok:
|
||||
return await interaction.response.send_message(f"❌ {err}", ephemeral=True)
|
||||
|
||||
await interaction.response.send_message("🔁 Restart requested — logging to modlog and restarting…", ephemeral=True)
|
||||
|
||||
guild = interaction.guild
|
||||
stamp = _now_utc_str()
|
||||
ver = _current_version(interaction.client) or "unknown"
|
||||
|
||||
log = (
|
||||
"🔁 **Bot Restart Requested**\n"
|
||||
f"**By:** {interaction.user.mention}\n"
|
||||
f"**When:** {stamp}\n"
|
||||
f"**Running version:** `{ver}`\n"
|
||||
f"**Reason:** {reason.strip()}"
|
||||
)
|
||||
if guild:
|
||||
await _send_modlog(interaction.client, guild, log)
|
||||
else:
|
||||
print("[power] no guild on interaction; modlog not sent.")
|
||||
|
||||
await _graceful_restart(interaction.client, delay=2.0)
|
||||
|
||||
# ---------------- setup ----------------
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
cog = PowerActionsCog(bot)
|
||||
await bot.add_cog(cog)
|
||||
|
||||
home_gid = cfg(bot).int('home_guild_id', 0)
|
||||
guild_obj = discord.Object(id=home_gid) if home_gid else None
|
||||
|
||||
# remove any prior 'power' root to keep reloads idempotent
|
||||
def _rm(name: str):
|
||||
try:
|
||||
bot.tree.remove_command(name, guild=guild_obj)
|
||||
except Exception:
|
||||
try:
|
||||
bot.tree.remove_command(name, guild=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_rm("power")
|
||||
if home_gid:
|
||||
bot.tree.add_command(cog.power, guild=guild_obj)
|
||||
print("[power] Registered /power group to home guild", home_gid)
|
||||
else:
|
||||
bot.tree.add_command(cog.power)
|
||||
print("[power] Registered /power group globally")
|
@ -6,6 +6,7 @@ from typing import List, Dict, Tuple, Optional
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
from modules.common.settings import cfg
|
||||
|
||||
# Accept both for backward compatibility; display uses "Refiner"
|
||||
VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"}
|
||||
@ -562,10 +563,10 @@ class SpicePayCog(commands.Cog):
|
||||
self.bot = bot
|
||||
self.sessions: Dict[tuple, Dict] = {}
|
||||
|
||||
cfg = bot.config['DEFAULT']
|
||||
r = cfg(bot)
|
||||
def _f(key, default):
|
||||
try:
|
||||
return float(cfg.get(key, str(default)))
|
||||
return float(r.get(key, str(default)))
|
||||
except Exception:
|
||||
return float(default)
|
||||
self.base_weight = _f('spicepay_base_weight', 25.0)
|
||||
@ -575,7 +576,8 @@ class SpicePayCog(commands.Cog):
|
||||
|
||||
def _i(key):
|
||||
try:
|
||||
return int(cfg.get(key)) if cfg.get(key) else None
|
||||
v = r.get(key, "")
|
||||
return int(v) if v else None
|
||||
except Exception:
|
||||
return None
|
||||
self.emoji_sand_id = _i('emoji_sand_id')
|
||||
@ -679,7 +681,7 @@ class SpicePayCog(commands.Cog):
|
||||
f"- Base weight: **{self.base_weight} × active %**\n"
|
||||
f"- Carrier bonus: **+{self.carrier_bonus}**\n"
|
||||
f"- Crawler bonus: **+{self.crawler_bonus}**\n\n"
|
||||
"_Edit these in `settings.conf` under `[DEFAULT]` and restart the bot._"
|
||||
"_Set via environment variables or your INI. Restart the bot after changing._"
|
||||
)
|
||||
await interaction.response.send_message(txt, ephemeral=True)
|
||||
|
||||
@ -895,23 +897,6 @@ class SpicePayCog(commands.Cog):
|
||||
|
||||
# ------------------------ setup ------------------------
|
||||
|
||||
async def setup(bot):
|
||||
async def setup(bot: commands.Bot):
|
||||
cog = SpicePayCog(bot)
|
||||
await bot.add_cog(cog)
|
||||
|
||||
try:
|
||||
home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
|
||||
except Exception:
|
||||
home_gid = 0
|
||||
|
||||
if home_gid:
|
||||
guild_obj = discord.Object(id=home_gid)
|
||||
bot.tree.add_command(cog.spicepay, guild=guild_obj)
|
||||
bot.tree.add_command(cog.spicepay_resume, guild=guild_obj)
|
||||
bot.tree.add_command(cog.spicepay_cancel, guild=guild_obj)
|
||||
bot.tree.add_command(cog.spicepay_config, guild=guild_obj)
|
||||
else:
|
||||
bot.tree.add_command(cog.spicepay)
|
||||
bot.tree.add_command(cog.spicepay_resume)
|
||||
bot.tree.add_command(cog.spicepay_cancel)
|
||||
bot.tree.add_command(cog.spicepay_config)
|
||||
|
0
modules/status/__init__.py
Normal file
331
modules/status/status_rotator.py
Normal file
@ -0,0 +1,331 @@
|
||||
# modules/status/status_rotator.py
|
||||
import random
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
from modules.common.settings import cfg
|
||||
|
||||
# ============== Tunables / lists you can expand freely ==============
|
||||
|
||||
# Long list of expressive unicode emojis (safe for all clients)
|
||||
EMOTES = [
|
||||
"❤️","🧡","💛","💚","💙","💜","🤎","🖤","🤍","✨","🌟","⭐","🎉","🎊","🔥","💫","⚡","🌈",
|
||||
"😄","😁","😆","😊","🙂","😉","😎","🤩","🥳","🤗","🙌","👏","👍","🤝","🫶","🙏","🫡","🤘","💪",
|
||||
"👀","🤔","🧐","😼","😹","😏","😌","😇","😴","🤖","👾","🧠","🫠",
|
||||
"🏜️","🌵","🐪","🐛","🪱","🧂","🧪","🗡️","⚔️","🛡️","🚁","🛩️","🚀","🧭","🌪️"
|
||||
]
|
||||
|
||||
DUNE_PHRASES = [
|
||||
"Arrakis. Dune. Desert Planet.",
|
||||
"Shai-Hulud stirs beneath the sands.",
|
||||
"The spice must flow.",
|
||||
"Bene Gesserit whispers in the dark.",
|
||||
"Kwisatz Haderach foretold.",
|
||||
"House Atreides rises.",
|
||||
"House Harkonnen plots.",
|
||||
"Fremen walk without rhythm.",
|
||||
"Crysknife unsheathed.",
|
||||
"Sietch Tabr stands strong.",
|
||||
"CHOAM counts its profits.",
|
||||
"The Spacing Guild navigates the void.",
|
||||
"Water is life.",
|
||||
"Fear is the mind-killer.",
|
||||
"Gom Jabbar at the throat.",
|
||||
"Stillsuits conserve every drop.",
|
||||
"Ornithopters in the storm.",
|
||||
"Sardaukar march.",
|
||||
"Prescience veils the future.",
|
||||
"Fedāykin watchful in the dunes.",
|
||||
"Shields hum under the sun.",
|
||||
"Kanly declared.",
|
||||
"Desert winds whisper secrets.",
|
||||
"Muad’Dib walks the golden path.",
|
||||
"Sandtrout seal the desert’s fate.",
|
||||
"Made with ❤️ in 🇳🇴",
|
||||
"DD Reset: Tuesday 03:00 UTC"
|
||||
]
|
||||
|
||||
FUN_FACTS = [
|
||||
"Frank Herbert conceived Dune after reporting on sand dune stabilization in the Oregon coast.",
|
||||
"‘Muad’Dib’ is a small desert mouse whose footprints taught Paul the sandwalk.",
|
||||
"Melange extends life, heightens awareness, and enables prescient navigation.",
|
||||
"Fremen ‘walking without rhythm’ avoids attracting sandworms.",
|
||||
"The crysknife is made from a sandworm’s tooth and must never be sheathed unblooded.",
|
||||
"Spacing Guild Navigators require spice to safely fold space.",
|
||||
"The Litany Against Fear is recited to focus and master one’s emotions.",
|
||||
"Bene Gesserit use prana-bindu training to control every muscle fiber.",
|
||||
"Sietch means a Fremen community cave complex.",
|
||||
"Stillsuits can reclaim over 90% of the body’s moisture.",
|
||||
"The Imperial throne balances the Landsraad, CHOAM, and the Guild.",
|
||||
"Kanly refers to a formal vendetta between noble houses.",
|
||||
"Arrakis once had open water—long before the events of Dune.",
|
||||
"‘Shai-Hulud’ is the Fremen name for the grand sandworm.",
|
||||
"A lasgun-shield interaction can cause a subatomic chain reaction.",
|
||||
"Mentats are human computers trained to replace forbidden thinking machines.",
|
||||
"Duncan Idaho appears across eras via ghola rebirths.",
|
||||
"The Water of Life is a deadly spice exhalation—surviving it transforms the Reverend Mother.",
|
||||
"Fedaykin are the Fremen elite commandos sworn to their leader.",
|
||||
"Sandtrout are juvenile forms of the great sandworms.",
|
||||
"The Butlerian Jihad outlawed thinking machines, reshaping human society.",
|
||||
"Caladan is the Atreides ocean world before their move to Arrakis.",
|
||||
"The Harkonnen homeworld is Giedi Prime, an industrialized, harsh planet.",
|
||||
"‘He who controls the spice controls the universe.’",
|
||||
"The Weirding Way is a Bene Gesserit martial art emphasizing speed and economy.",
|
||||
"Sardaukar troops are trained from birth on the prison planet Salusa Secundus.",
|
||||
"Ornithopters mimic bird flight to navigate harsh desert storms.",
|
||||
"The Fremen call offworlders ‘water-fat’ as an insult.",
|
||||
"Spice blows are natural melange eruptions from the deep desert.",
|
||||
"A ~30 year old male weighing ~75kg will consist of around 45L of water.",
|
||||
"A simple interactive DD map can be found at https://dune.gaming.tools/deep-desert."
|
||||
]
|
||||
|
||||
|
||||
# ============== Cog implementation ==============
|
||||
|
||||
class StatusRotatorCog(commands.Cog):
|
||||
"""
|
||||
Cycles bot presence every N minutes with randomized status entries:
|
||||
- Member count (excluding bots)
|
||||
- Pirates count
|
||||
- Encounters count
|
||||
- Random shout-out: {emoji} {display_name} (members with SHAI_FULL_ACCESS_ROLE_ID)
|
||||
- Count of fully initiated members (role members)
|
||||
- Random Dune phrase
|
||||
- Random Dune fun fact (#n -> fact)
|
||||
- Top threat pirate
|
||||
- Latest report (pending/most recent)
|
||||
- Encounters in last 24h
|
||||
- Uptime
|
||||
- Current running version
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
r = cfg(bot)
|
||||
self.home_gid = r.int('home_guild_id', 0)
|
||||
self.full_access_role_id = r.int('full_access_role_id', 0) # SHAI_FULL_ACCESS_ROLE_ID
|
||||
self.interval_min = r.int('status_interval_min', 5) # SHAI_STATUS_INTERVAL_MIN (optional)
|
||||
self.max_len = 120 # clip safety for PC clients (keeps presence tidy)
|
||||
|
||||
# Build generator list (enable/disable here if needed)
|
||||
self._generators = [
|
||||
self._gen_members_count,
|
||||
self._gen_pirates_count,
|
||||
self._gen_encounters_count,
|
||||
self._gen_random_shoutout,
|
||||
self._gen_initiated_count,
|
||||
self._gen_random_phrase,
|
||||
self._gen_random_fun_fact,
|
||||
self._gen_top_threat,
|
||||
self._gen_latest_report,
|
||||
self._gen_encounters_last_24h,
|
||||
self._gen_uptime,
|
||||
self._gen_running_version,
|
||||
]
|
||||
self._queue = []
|
||||
|
||||
# ---- lifecycle ----
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_ready(self):
|
||||
# ensure loop interval reflects config
|
||||
try:
|
||||
if self.rotate_status.is_running():
|
||||
self.rotate_status.change_interval(minutes=max(1, self.interval_min))
|
||||
else:
|
||||
self.rotate_status.change_interval(minutes=max(1, self.interval_min))
|
||||
self.rotate_status.start()
|
||||
except Exception as e:
|
||||
print("[status] failed to start/change loop:", repr(e))
|
||||
|
||||
def cog_unload(self):
|
||||
try:
|
||||
self.rotate_status.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- the loop ----
|
||||
|
||||
@tasks.loop(minutes=5)
|
||||
async def rotate_status(self):
|
||||
await self.bot.wait_until_ready()
|
||||
guild = self._resolve_guild()
|
||||
if not guild:
|
||||
return
|
||||
|
||||
text = await self._next_status_text(guild)
|
||||
if not text:
|
||||
return
|
||||
|
||||
# Enforce Custom Status; if it fails for any reason, fall back to Watching.
|
||||
try:
|
||||
await self.bot.change_presence(activity=discord.CustomActivity(name=text))
|
||||
except Exception as e:
|
||||
print("[status] custom presence failed, falling back to Watching:", repr(e))
|
||||
try:
|
||||
await self.bot.change_presence(
|
||||
activity=discord.Activity(type=discord.ActivityType.watching, name=text)
|
||||
)
|
||||
except Exception as e2:
|
||||
print("[status] change_presence failed:", repr(e2))
|
||||
|
||||
# ---- helpers ----
|
||||
|
||||
def _resolve_guild(self) -> discord.Guild | None:
|
||||
if self.home_gid:
|
||||
g = self.bot.get_guild(self.home_gid)
|
||||
if g:
|
||||
return g
|
||||
return self.bot.guilds[0] if self.bot.guilds else None
|
||||
|
||||
async def _next_status_text(self, guild: discord.Guild) -> str | None:
|
||||
# Try up to len(generators) times to get a non-empty status
|
||||
for _ in range(len(self._generators)):
|
||||
if not self._queue:
|
||||
self._queue = random.sample(self._generators, k=len(self._generators))
|
||||
gen = self._queue.pop(0)
|
||||
try:
|
||||
s = await gen(guild)
|
||||
s = self._clip(s)
|
||||
if s:
|
||||
return s
|
||||
except Exception as e:
|
||||
print(f"[status] generator {gen.__name__} failed:", repr(e))
|
||||
continue
|
||||
return None
|
||||
|
||||
def _clip(self, s: str | None) -> str | None:
|
||||
if not s:
|
||||
return None
|
||||
s = s.strip()
|
||||
return (s[: self.max_len - 1] + "…") if len(s) > self.max_len else s
|
||||
|
||||
@staticmethod
|
||||
def _fmt_duration(seconds: float) -> str:
|
||||
seconds = int(max(0, seconds))
|
||||
d, rem = divmod(seconds, 86400)
|
||||
h, rem = divmod(rem, 3600)
|
||||
m, _ = divmod(rem, 60)
|
||||
if d > 0:
|
||||
return f"{d}d {h}h {m}m"
|
||||
if h > 0:
|
||||
return f"{h}h {m}m"
|
||||
return f"{m}m"
|
||||
|
||||
# ---- individual generators (originals) ----
|
||||
|
||||
async def _gen_members_count(self, guild: discord.Guild) -> str:
|
||||
count = sum(1 for m in guild.members if not m.bot)
|
||||
return f"{count} server members"
|
||||
|
||||
async def _gen_pirates_count(self, guild: discord.Guild) -> str:
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
n = len(dm.get('pirates')) if dm else 0
|
||||
return f"{n} pirates reported"
|
||||
|
||||
async def _gen_encounters_count(self, guild: discord.Guild) -> str:
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
n = len(dm.get('encounters')) if dm else 0
|
||||
return f"{n} reported encounters"
|
||||
|
||||
async def _gen_random_shoutout(self, guild: discord.Guild) -> str | None:
|
||||
if not self.full_access_role_id:
|
||||
return None
|
||||
role = guild.get_role(self.full_access_role_id)
|
||||
if not role:
|
||||
return None
|
||||
candidates = [m for m in role.members if not m.bot]
|
||||
if not candidates:
|
||||
return None
|
||||
member = random.choice(candidates)
|
||||
em = random.choice(EMOTES)
|
||||
name = member.display_name
|
||||
return f"{em} {name}"
|
||||
|
||||
async def _gen_initiated_count(self, guild: discord.Guild) -> str | None:
|
||||
if not self.full_access_role_id:
|
||||
return None
|
||||
role = guild.get_role(self.full_access_role_id)
|
||||
if not role:
|
||||
return None
|
||||
n = sum(1 for m in role.members if not m.bot)
|
||||
return f"{n} fully initiated members"
|
||||
|
||||
async def _gen_random_phrase(self, guild: discord.Guild) -> str:
|
||||
return random.choice(DUNE_PHRASES)
|
||||
|
||||
async def _gen_random_fun_fact(self, guild: discord.Guild) -> str:
|
||||
idx = random.randrange(len(FUN_FACTS)) # 0-based
|
||||
num = idx + 1
|
||||
fact = FUN_FACTS[idx]
|
||||
return f"Fun fact #{num} ->\n{fact}"
|
||||
|
||||
async def _gen_top_threat(self, guild: discord.Guild) -> str | None:
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
pirates = dm.get('pirates') if dm else []
|
||||
if not pirates:
|
||||
return None
|
||||
# sort by threat_level desc, then encounter_count desc, then newest added
|
||||
pirates_sorted = sorted(
|
||||
pirates,
|
||||
key=lambda p: (
|
||||
int(p.get('threat_level', 0)),
|
||||
int(p.get('encounter_count', 0)),
|
||||
float(p.get('added_ts', 0.0))
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
top = pirates_sorted[0]
|
||||
tl = int(top.get('threat_level', 0))
|
||||
name = top.get('character_name') or top.get('account_name') or "Unknown"
|
||||
return f"Top threat: {name} ({tl}%)"
|
||||
|
||||
async def _gen_latest_report(self, guild: discord.Guild) -> str | None:
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
reports = dm.get('reports') if dm else []
|
||||
if not reports:
|
||||
return None
|
||||
latest = max(reports, key=lambda r: float(r.get('ts', 0.0)))
|
||||
char = latest.get('character_name') or "Unknown"
|
||||
acct = latest.get('account_name') or ""
|
||||
# keep brief
|
||||
return f"Latest report: {char}" + (f" ({acct})" if acct else "")
|
||||
|
||||
async def _gen_encounters_last_24h(self, guild: discord.Guild) -> str | None:
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
encs = dm.get('encounters') if dm else []
|
||||
if not encs:
|
||||
return "0 encounters last 24h"
|
||||
since = time.time() - 86400
|
||||
n = sum(1 for e in encs if float(e.get('timestamp', 0.0)) >= since)
|
||||
return f"{n} encounters last 24h"
|
||||
|
||||
async def _gen_uptime(self, guild: discord.Guild) -> str | None:
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
st = (dm.get('boot_state') or [{}])[-1] if (dm and dm.get('boot_state')) else {}
|
||||
boot_ts = float(st.get('last_boot_ts', 0.0))
|
||||
if boot_ts <= 0:
|
||||
return None
|
||||
dur = self._fmt_duration(time.time() - boot_ts)
|
||||
return f"Uptime {dur}"
|
||||
|
||||
async def _gen_running_version(self, guild: discord.Guild) -> str | None:
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
st = (dm.get('boot_state') or [{}])[-1] if (dm and dm.get('boot_state')) else {}
|
||||
ver = st.get('last_version')
|
||||
if not ver:
|
||||
return None
|
||||
# Occasionally include version (kept as its own generator for randomness)
|
||||
return f"Running v{ver}"
|
||||
|
||||
# ============== setup() ==============
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
cog = StatusRotatorCog(bot)
|
||||
await bot.add_cog(cog)
|
||||
print("[status] StatusRotatorCog loaded; rotating presence every",
|
||||
max(1, cfg(bot).int('status_interval_min', 5)), "minute(s).")
|
0
modules/usage/__init__.py
Normal file
36
modules/usage/usage_stats.py
Normal file
@ -0,0 +1,36 @@
|
||||
# modules/usage/usage_stats.py
|
||||
from __future__ import annotations
|
||||
from discord.ext import commands
|
||||
import discord
|
||||
|
||||
COUNTER_KEY_PREFIX = "cmd::"
|
||||
|
||||
def _key_from_app(cmd: discord.app_commands.Command) -> str:
|
||||
return f"{COUNTER_KEY_PREFIX}{getattr(cmd, 'qualified_name', None) or getattr(cmd, 'name', 'unknown')}"
|
||||
|
||||
class UsageStatsCog(commands.Cog):
|
||||
"""Slash-only metrics; count once per successful app command completion."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
print("[usage] UsageStatsCog init (slash-only)")
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command):
|
||||
dm = getattr(self.bot, "data_manager", None)
|
||||
if not dm:
|
||||
return
|
||||
try:
|
||||
key = _key_from_app(command)
|
||||
newv = dm.incr_counter(key, 1)
|
||||
print(f"[usage] app ++ {key} -> {newv}")
|
||||
except Exception as e:
|
||||
print("[usage] app !! incr failed:", repr(e))
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
if getattr(bot, "_usage_stats_loaded", False):
|
||||
print("[usage] UsageStatsCog already loaded; skipping duplicate add")
|
||||
return
|
||||
await bot.add_cog(UsageStatsCog(bot))
|
||||
bot._usage_stats_loaded = True
|
||||
print("[usage] UsageStatsCog loaded (slash-only)")
|
@ -1,9 +1,12 @@
|
||||
# modules/user_cards/user_cards.py
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional, Set, Tuple
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
from modules.common.emoji_accept import is_accept
|
||||
from modules.common.settings import cfg # ENV-first helper
|
||||
|
||||
CHECK = '✅' # verified
|
||||
CROSS = '❌' # not done
|
||||
@ -11,6 +14,7 @@ PENDING = '✔️' # claimed / pending review
|
||||
ACCEPT = {CHECK, '🫡'}
|
||||
NO_MENTIONS = discord.AllowedMentions.none()
|
||||
|
||||
|
||||
class UserCardsCog(commands.Cog):
|
||||
"""
|
||||
Per-user status cards with live reconcile and offline review triggers.
|
||||
@ -25,34 +29,35 @@ class UserCardsCog(commands.Cog):
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
cfg = bot.config['DEFAULT']
|
||||
self.userslist_channel_id = int(cfg['userslist_channel_id'])
|
||||
self.modlog_channel_id = int(cfg['modlog_channel_id'])
|
||||
self.mod_channel_id = int(cfg.get('mod_channel_id', '0') or 0)
|
||||
r = cfg(bot)
|
||||
|
||||
# Channels / IDs from ENV/INI
|
||||
self.userslist_channel_id = r.int('userslist_channel_id', 0)
|
||||
self.modlog_channel_id = r.int('modlog_channel_id', 0)
|
||||
self.mod_channel_id = r.int('mod_channel_id', 0)
|
||||
|
||||
# reaction-role authoritative messages/roles
|
||||
self.rules_msg_id = int(cfg['rules_message_id'])
|
||||
self.engage_msg_id = int(cfg['engagement_message_id'])
|
||||
self.nick_msg_id = int(cfg['nickname_message_id'])
|
||||
self.rules_role_id = int(cfg['rules_role_id'])
|
||||
self.engage_role_id = int(cfg['engagement_role_id'])
|
||||
self.full_access_role_id = int(cfg['full_access_role_id'])
|
||||
self.rules_msg_id = r.int('rules_message_id', 0)
|
||||
self.engage_msg_id = r.int('engagement_message_id', 0)
|
||||
self.nick_msg_id = r.int('nickname_message_id', 0)
|
||||
self.rules_role_id = r.int('rules_role_id', 0)
|
||||
self.engage_role_id = r.int('engagement_role_id', 0)
|
||||
self.full_access_role_id = r.int('full_access_role_id', 0)
|
||||
|
||||
self._refresh_locks = {} # per-user locks to avoid racey double-posts
|
||||
|
||||
# Optional periodic refresh (twice a day)
|
||||
try:
|
||||
self.cron_enabled = cfg.getboolean('user_cards_cron_enabled')
|
||||
except Exception:
|
||||
self.cron_enabled = False
|
||||
|
||||
self.cron_enabled = r.bool('user_cards_cron_enabled', False)
|
||||
self._cron_task = asyncio.create_task(self._periodic_refresh()) if self.cron_enabled else None
|
||||
self._startup_task = asyncio.create_task(self._startup_reconcile())
|
||||
|
||||
def cog_unload(self):
|
||||
for t in (self._cron_task, self._startup_task):
|
||||
if t:
|
||||
try: t.cancel()
|
||||
except Exception: pass
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------- status helpers ----------
|
||||
|
||||
@ -126,7 +131,7 @@ class UserCardsCog(commands.Cog):
|
||||
if member.avatar:
|
||||
embed.set_thumbnail(url=member.avatar.url)
|
||||
|
||||
# NEW: stable identity so we can find/edit the right card later
|
||||
# Stable identity so we can find/edit the right card later
|
||||
embed.set_footer(text=f"UID:{member.id}")
|
||||
return embed
|
||||
|
||||
@ -137,7 +142,7 @@ class UserCardsCog(commands.Cog):
|
||||
2) If not found, search the channel by footer marker and edit that.
|
||||
3) If still not found, post a new one, then delete any stragglers with the same marker.
|
||||
"""
|
||||
if not member or not member.guild:
|
||||
if not member or not member.guild or not self.userslist_channel_id:
|
||||
return
|
||||
|
||||
async with self._lock_for(member.id):
|
||||
@ -183,11 +188,13 @@ class UserCardsCog(commands.Cog):
|
||||
return
|
||||
|
||||
# 4) Post fresh card
|
||||
new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS)
|
||||
try:
|
||||
new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# 5) Clean up any other messages that look like this user's card
|
||||
try:
|
||||
# Find any *other* occurrences with the same footer marker and delete them
|
||||
marker = f"UID:{member.id}"
|
||||
async for m in channel.history(limit=400, oldest_first=False):
|
||||
if m.id == new_msg.id or m.author.id != self.bot.user.id or not m.embeds:
|
||||
@ -211,7 +218,7 @@ class UserCardsCog(commands.Cog):
|
||||
lk = asyncio.Lock()
|
||||
self._refresh_locks[user_id] = lk
|
||||
return lk
|
||||
|
||||
|
||||
async def _find_existing_card(self, channel: discord.TextChannel, user_id: int) -> Optional[discord.Message]:
|
||||
"""Search recent history for a card we posted for this user (by footer marker)."""
|
||||
marker = f"UID:{user_id}"
|
||||
@ -229,10 +236,14 @@ class UserCardsCog(commands.Cog):
|
||||
return None
|
||||
|
||||
async def _log(self, guild: discord.Guild, content: str):
|
||||
if not self.modlog_channel_id:
|
||||
return
|
||||
ch = guild.get_channel(self.modlog_channel_id)
|
||||
if ch:
|
||||
try: await ch.send(content, allowed_mentions=NO_MENTIONS)
|
||||
except Exception: pass
|
||||
try:
|
||||
await ch.send(content, allowed_mentions=NO_MENTIONS)
|
||||
except Exception:
|
||||
pass
|
||||
self.bot.data_manager.add('modlog', {'guild_id': guild.id, 'content': content})
|
||||
|
||||
# ---------- RR message lookup & reactor collection ----------
|
||||
@ -249,6 +260,8 @@ class UserCardsCog(commands.Cog):
|
||||
dm.add('rr_msg_channels', {'guild_id': guild_id, 'message_id': int(message_id), 'channel_id': int(channel_id)})
|
||||
|
||||
async def _get_message_by_id(self, guild: discord.Guild, message_id: int) -> Optional[discord.Message]:
|
||||
if not message_id:
|
||||
return None
|
||||
ch_id = self._get_cached_msg_channel_id(guild.id, message_id)
|
||||
if ch_id:
|
||||
ch = guild.get_channel(ch_id)
|
||||
@ -362,7 +375,7 @@ class UserCardsCog(commands.Cog):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- New part: open reviews for *any* unreviewed claimers (startup/offline) ---
|
||||
# --- Open reviews for *any* unreviewed claimers (startup/offline) ---
|
||||
nn = self.bot.get_cog('NickNudgeCog')
|
||||
|
||||
verified_set = set(dm.get('nick_verified'))
|
||||
@ -443,8 +456,10 @@ class UserCardsCog(commands.Cog):
|
||||
for g in self.bot.guilds:
|
||||
m = g.get_member(after.id)
|
||||
if m:
|
||||
try: await self.refresh_card(m)
|
||||
except Exception: pass
|
||||
try:
|
||||
await self.refresh_card(m)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------- periodic + startup ----------
|
||||
|
||||
@ -458,16 +473,20 @@ class UserCardsCog(commands.Cog):
|
||||
for g in list(self.bot.guilds):
|
||||
for m in g.members:
|
||||
if not m.bot:
|
||||
try: await self.refresh_card(m)
|
||||
except Exception: pass
|
||||
try:
|
||||
await self.refresh_card(m)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _periodic_refresh(self):
|
||||
await self.bot.wait_until_ready()
|
||||
while not self.bot.is_closed():
|
||||
try:
|
||||
for g in self.bot.guilds:
|
||||
try: await self._reconcile_agreements(g)
|
||||
except Exception: pass
|
||||
try:
|
||||
await self._reconcile_agreements(g)
|
||||
except Exception:
|
||||
pass
|
||||
for m in g.members:
|
||||
if not m.bot:
|
||||
await self.refresh_card(m)
|
||||
@ -476,20 +495,18 @@ class UserCardsCog(commands.Cog):
|
||||
await asyncio.sleep(12 * 60 * 60) # twice a day
|
||||
|
||||
# ---------- mod command: rescan + live reconcile ----------
|
||||
@app_commands.command(name="usercards_rescan", description="[MOD] Re-check all users and refresh cards")
|
||||
@app_commands.default_permissions(manage_guild=True)
|
||||
@app_commands.checks.has_permissions(manage_guild=True)
|
||||
async def usercards_rescan(self, interaction: discord.Interaction):
|
||||
if not interaction.guild:
|
||||
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
|
||||
|
||||
@commands.hybrid_command(
|
||||
name="usercards_rescan",
|
||||
description="Re-check all users and refresh cards (mod-only)"
|
||||
)
|
||||
@commands.has_permissions(manage_guild=True)
|
||||
async def usercards_rescan(self, ctx: commands.Context):
|
||||
g = ctx.guild
|
||||
if not g:
|
||||
return await ctx.reply("Use this in a server.", ephemeral=True)
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
g = interaction.guild
|
||||
rchg, echg, nadd, nrem = await self._reconcile_agreements(g)
|
||||
|
||||
# Rebuild cards
|
||||
updated = 0
|
||||
for m in g.members:
|
||||
if not m.bot:
|
||||
@ -499,7 +516,7 @@ class UserCardsCog(commands.Cog):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await ctx.reply(
|
||||
await interaction.followup.send(
|
||||
f"Reconciled from messages. Changes — Rules: **{rchg}**, RoE: **{echg}**, "
|
||||
f"Nickname (added): **{nadd}**, Nickname (removed): **{nrem}**. "
|
||||
f"Refreshed cards for **{updated}** members.",
|
||||
|
@ -1,2 +1,4 @@
|
||||
discord.py>=2.3.2
|
||||
python-dotenv
|
||||
discord.py>=2.5.2
|
||||
python-dotenv>=1.0.1
|
||||
aiohttp>=3.9,<4
|
||||
playwright==1.45.0
|
||||
|