Compare commits

..

59 Commits

Author SHA1 Message Date
Franz Rolfsvaag
ac9953fed6 0.5.1.2.a4
- Minor patch to prevent non-initiated members from claiming crew roles
2025-08-26 00:07:51 +02:00
Franz Rolfsvaag
9b94280e8b 0.5.1.2.a3
- Small patch to repair orphaned fedaykin requests
2025-08-25 23:57:39 +02:00
Franz Rolfsvaag
f3bc0ef670 0.5.1.2.a2
- Adjustments to reaction role
  - Ensures no one can hold the Fedaykin role without a head
  - Stores pending reviews in the absence of a head for later review when a new head is reinstated
  - *Thanks Kenny :P*
2025-08-25 23:47:25 +02:00
Franz Rolfsvaag
3d807e2fc2 0.5.1.2.a1
- Added crew role reaction roles:
  - Harvester Crew - for carrier and crawler operators/owners
  - Escort Crew - for players protecting harvester crews
  - Fedaykin - for pirate hunters
- Minor bugfixes to other reaction role features, most notably duplicate reviews on auto-detect
2025-08-25 10:55:48 +02:00
Franz Rolfsvaag
23e122c08a 0.5.1.1.a1
- Major back-end changes
  - Moved most non-sensitive values to dynamic configurations module
  - Removed references to old variables naming scheme for certain settings
  - Essentially, most settings are now capable of being dynamically assigned, instead of fully static
  - Complete rework of the wrapper and compose infrastructure to allow dynamic data changes
- New command: `/shaiadmin`
  - Admin-only (and approved users/roles) have access
  - `/shaiadmin set [setting] [value]`
    - Applies a new setting
    - Settings must be properly formatted, and invalid ones are rejected
  - `/shaiadmin unset [setting]`
    - Unsets/resets a setting to default
  - `/shaiadmin settings download`
    - Offers the current settings file for download. Useful for bulk editing. JSON formatted
  - `/shaiadmin settings upload [file].json`
    - Allows the uploading of a new settings file
    - This file is verified, tested, and processed before being applied
2025-08-24 15:27:10 +02:00
b56ed48f8d Update requirements.txt 2025-08-16 06:25:50 +00:00
Franz Rolfsvaag
ebbebbacf7 0.4.2.1.a1
- Added a new owner-only `/data [download/upload]` command for datafile backup and restoration
*This is required as v0.4.2 requires a rebuild of the stack, and the existing data should be backed up in case of data loss*
2025-08-16 07:31:13 +02:00
Franz Rolfsvaag
1ede582a76 0.4.2.0.a1
- DD cycle data fetching
  - ShaiWatcher will now keep an updated loot table of the unique items in the DD each week
    The bot will **only** edit its message if already present, which should reduce message spam
  - Added command `/dd_update` to control the update behaviour. stop|resume|start [reason_text]
- Docsite changes
  - Added "ADMIN" tags to commands, signifying owner-only commands
  - Owner-only commands are now filtered under the "moderator" category
  - Added docs for `/dd_update`
- Logging
  - Added logging info for more verbose info relating to configuration and installation
2025-08-16 06:39:01 +02:00
Franz Rolfsvaag
77f92abe19 0.4.1.0.a8
- Converted all commands to slash-only commands
2025-08-16 03:12:33 +02:00
Franz Rolfsvaag
0349c36880 0.4.1.0.a7
- Changed interaction check on command executions in an attempt to prevent hybrid commands from counting twice on the docsite
2025-08-16 02:56:46 +02:00
Franz Rolfsvaag
dab1e4e9e0 0.4.1.0.a6
- I forgot hybrid commands call two triggers whenever executed, thus messing with the counter on the docsite
  - *Let's see if this fixes things..*
2025-08-16 02:50:57 +02:00
Franz Rolfsvaag
fe09e1dd1f 0.4.1.0.a5
- Yet another patch for the exectuion counter..
  - *When stuff won't play nice, you brute-force it*
2025-08-16 02:40:38 +02:00
Franz Rolfsvaag
73175bbecd 0.4.1.0.a4
- Correction to earlier patch. *Testing on live server is a pain, but I'm too lazy to fire up a dev server..*
2025-08-16 02:36:16 +02:00
Franz Rolfsvaag
730d479e2d 0.4.1.0.a3
- Slight bugfix to docsite execution counter due to global command tree silently resulting in duplicate triggers
2025-08-16 02:31:38 +02:00
Franz Rolfsvaag
4e86eb43fc 0.4.1.0.a2
- Docs site changes
  - Details brief no longer opens automatically on narrower devices
  - Implemented a counter that displays the number of executions for each command
2025-08-16 02:26:49 +02:00
Franz Rolfsvaag
fdd336fe91 0.4.1.0.a1
- Doc site UI improvements and fixes
  - Added Discord widget
  - Fixed some styling issues
  - Added command docs briefs and details
    - Briefs are shown in the right-hand panel
    - Details can be shown by clicking `Open full details`
  - Added ShaiWatchers' logo as the site favicon
  - Moved HTML template to static file for responsiveness improvements
2025-08-15 06:17:52 +02:00
Franz Rolfsvaag
d768712b75 0.4.0.0.a5
- Minor styling fix for the docsite footer
- Cleaned up data, variables, etc. relating to the docsite
2025-08-13 13:06:28 +02:00
Franz Rolfsvaag
985888fb5a 0.4.0.0.a4
Fixed small typos
2025-08-13 12:55:08 +02:00
Franz Rolfsvaag
87bcc61a1a 0.4.0.0.a3
- Added linkable and sharable commands
- Added copy-buttons to command fields
2025-08-13 12:47:34 +02:00
Franz Rolfsvaag
66447865f5 0.4.0.0.a2
- Bugfix for commands not fully populating docs site
  - Deployed version uses global commands. The docs site should now pick these up as well
2025-08-13 12:28:44 +02:00
Franz Rolfsvaag
47cc285919 0.4.0.0.a1
- Fully implemented swagger-like docs site
  - Full search functionality
  - Command type tags
  - Usage examples
  - Mobile-friendly
  - Command details available through the "Details" panel
  - Dynamically keeps information up-to-date
2025-08-13 12:19:09 +02:00
Franz Rolfsvaag
21a79194dd 0.3.9.8.a1
- Added an experimental small doc-site
  - Automatically fetches and displays command syntax and other details
  - Lightweight and secure with no edit functionality
- Minor description changes for clarity
- Added a few more status texts
2025-08-13 08:58:56 +02:00
Franz Rolfsvaag
aab931b543 0.3.9.7.a5
- Forgot some command labels for some mod commands
- `/recreate` -> `/recreate_nick_review` for clarity
  - This recreates reviews for a single user, if applicable
  - The plural command still affects all applicable users as normal
2025-08-11 09:58:44 +02:00
Franz Rolfsvaag
21f6150842 0.3.9.7.a4
- Fixed permissions module not affecting certain features
- Added clear `[MOD]` labels to moderator-only commands for clarity
2025-08-11 09:53:35 +02:00
Franz Rolfsvaag
4f0e000c93 0.3.9.7.a3
Changes to Pirate Reports:
- Proof is no longer **required** but **encouraged**.
- Mods can click a button on the review to jump to the report ACK message
2025-08-11 09:26:56 +02:00
Franz Rolfsvaag
b6980794d7 0.3.9.7.a2
Simple fix to (hopefully) remove status prefixes
2025-08-11 03:56:36 +02:00
Franz Rolfsvaag
8fb7a9dab5 0.3.9.7.a1
Added random presence rotator, because... fun!
2025-08-11 03:24:46 +02:00
Franz Rolfsvaag
eb1e1da82f 0.3.9.6.a5
Minor fix to remove duplicate slash commands
2025-08-11 02:58:25 +02:00
Franz Rolfsvaag
7c9ec713b7 0.3.9.6.a4
Minor fix to redundant commit messages being posted when no version change it performed
2025-08-11 02:31:59 +02:00
Franz Rolfsvaag
40ef32c530 0.3.9.6.a3
Cogified the restart command `/bot_restart {reason}` -> `/power restart {reason}` for future enhancements
2025-08-11 02:24:23 +02:00
Franz Rolfsvaag
95d91b6f3e 0.3.9.6.a2
Added verbose startup terminal message regarding slash commands availability
2025-08-11 02:14:58 +02:00
Franz Rolfsvaag
a25dca76e7 0.3.9.6.a1
Added `/bot_restart {reason}` command for moderators to restart the bot from Discord
2025-08-11 02:07:34 +02:00
Franz Rolfsvaag
b780c4069e 0.3.9.5.a4
Incorporated fault-tolerant startup messages with proper fetching of commit messages
2025-08-11 01:56:28 +02:00
Franz Rolfsvaag
0038a1889c 0.3.9.5.a3
Transitioned from RSS-based commit message fetching to API-based fetching for commit messages
2025-08-11 01:32:16 +02:00
Franz Rolfsvaag
2a898802b6 0.3.9.5.a2
Fixed issues relating to RSS feed not giving enough details
2025-08-11 01:18:01 +02:00
Franz Rolfsvaag
cd11e3106f 0.3.9.5.a1
Introduction of startup messages in modlog with version number
2025-08-11 00:52:21 +02:00
Franz Rolfsvaag
5f71ee8ebf 0.3.9.4.a3
Added support for Gitea multi-line commit messages to be posted as startup messages
2025-08-11 00:45:39 +02:00
Franz Rolfsvaag
6e85897ca8 0.3.9.4.a2
Added better startup messages being sent to the Discord modlog channel
2025-08-11 00:39:18 +02:00
Franz Rolfsvaag
b74002e69f 0.3.9.4.a1
Added image/video proof to pirate reports as a requirement. This is displayed in-line for moderators in the review message
2025-08-11 00:30:23 +02:00
Franz Rolfsvaag
7b5bcff6ac 0.3.9.3.a4
Added mod-tools to recreate nickname reviews
2025-08-11 00:18:10 +02:00
Franz Rolfsvaag
f14e84b89c 0.3.9.3.a3
Added /clear_nick_reviews mod-command: clears out pending nickname reviews from datafile
2025-08-11 00:02:00 +02:00
Franz Rolfsvaag
c09f36162d 0.3.9.3.a2
Fixes nick review lock, preventing re-reviews to be sent out despite genuine
2025-08-10 23:53:16 +02:00
Franz Rolfsvaag
268966a4ae 0.3.9.3.a1
Fixes duplicate nickname reviews when users react with multiple emojis
2025-08-10 23:37:14 +02:00
Franz Rolfsvaag
4e77cddc92 0.3.9.2.a10
Restructured runtime environment variables passing to cogs
2025-08-10 21:46:15 +02:00
Franz Rolfsvaag
1febca2243 0.3.9.2.a9
config bugfix
2025-08-10 21:22:41 +02:00
Franz Rolfsvaag
e2b6dd667b . 2025-08-10 21:13:26 +02:00
Franz Rolfsvaag
4d5258c89d 0.3.9.2.a8 2025-08-10 21:06:11 +02:00
Franz Rolfsvaag
c28bc573cc 0.3.9.2.a7
Fixes to config handling in main bot file because I overlooked it entirely, herpa derpa ..
2025-08-10 20:54:56 +02:00
Franz Rolfsvaag
377586e6e7 . 2025-08-10 20:53:09 +02:00
Franz Rolfsvaag
27cc972f19 Added version verbose on startup 2025-08-10 20:45:08 +02:00
Franz Rolfsvaag
c540f624af . 2025-08-10 20:41:50 +02:00
Franz Rolfsvaag
7222239774 . 2025-08-10 20:36:13 +02:00
Franz Rolfsvaag
5368d21be4 0.3.9.2.a5
performance improvements, stability, and primarily settings-handling improvements.
  - Due to the container transition, some settings handling became quietly broken or defunct.
2025-08-10 20:23:09 +02:00
Franz Rolfsvaag
9bdb286d38 Removed deprecated docker files 2025-08-10 19:41:50 +02:00
Franz Rolfsvaag
979a5ecd4f 0.3.9.2.a4 2025-08-10 19:07:17 +02:00
Franz Rolfsvaag
b152440241 0.3.9.2.a3
Attempted fix at docker boot issue
2025-08-10 18:49:24 +02:00
Franz Rolfsvaag
e6ccc86629 0.3.9.2.a2
Restart-related patch
2025-08-10 18:06:09 +02:00
Franz Rolfsvaag
36939efac3 0.3.9.a2
Fixed wrong version format
2025-08-10 17:24:39 +02:00
Franz Rolfsvaag
dee0c4a5b4 v0.3.9.a2
Added experimental bot wrapper functionality for completely automated updates and restarts
2025-08-10 17:17:59 +02:00
110 changed files with 10575 additions and 351 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
.git
__pycache__/
*.pyc
.env
settings.conf
data.json
data.json.bak
example/

127
.env.example Normal file
View 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 (dont change)
SHAI_DATA_FILE=/data/data.json
# ─────────────────────────────────────────────────────────────
# Volumes (external, never wiped by stack re-deploys)
# Create once: docker volume create shaiwatcher_data && docker volume create shaiwatcher_cache
# ─────────────────────────────────────────────────────────────
SHAI_VOL_DATA=shaiwatcher_data
SHAI_VOL_CACHE=shaiwatcher_cache
# ─────────────────────────────────────────────────────────────
# Playwright / Headless browser (for DD scraping)
# Build with: docker compose build --build-arg WITH_PLAYWRIGHT=1
# ─────────────────────────────────────────────────────────────
WITH_PLAYWRIGHT=1 # 1 to bake Chromium+Playwright into the image
SHAI_DD_CHANNEL_ID=<channel id for DD message>
SHAI_DD_FETCHER=playwright # playwright|aiohttp (playwright recommended)
SHAI_DD_PW_TIMEOUT_MS=60000 # nav timeout
SHAI_DD_PW_WAIT= # domcontentloaded|load|networkidle (empty = default domcontentloaded)
# ─────────────────────────────────────────────────────────────
# Slash command scope (your bot already honors this)
# ─────────────────────────────────────────────────────────────
SHAI_SLASH_GUILD_ONLY=true
SHAI_HOME_GUILD_ID=<home guild id>
# ─────────────────────────────────────────────────────────────
# Channels
# ─────────────────────────────────────────────────────────────
SHAI_MOD_CHANNEL_ID=<id>
SHAI_MODLOG_CHANNEL_ID=<id>
SHAI_USERSLIST_CHANNEL_ID=<id>
SHAI_REPORT_CHANNEL_ID=<id>
SHAI_PIRATES_LIST_CHANNEL_ID=<id>
SHAI_TRIGGER_CHANNEL_ID=<id> # Auto-VC
SHAI_AUTO_VC_CATEGORY_ID=<id>
# ─────────────────────────────────────────────────────────────
# Reaction messages
# ─────────────────────────────────────────────────────────────
SHAI_RULES_MESSAGE_ID=<id>
SHAI_ENGAGEMENT_MESSAGE_ID=<id>
SHAI_NICKNAME_MESSAGE_ID=<id>
# ─────────────────────────────────────────────────────────────
# Roles
# ─────────────────────────────────────────────────────────────
SHAI_RULES_ROLE_ID=<id>
SHAI_ENGAGEMENT_ROLE_ID=<id>
SHAI_FULL_ACCESS_ROLE_ID=<id>
SHAI_ADMIN_ROLE_ID=<id>
SHAI_FIELD_MOD_ROLE_ID=<id>
SHAI_INTEL_MOD_ROLE_ID=<id>
SHAI_MODERATOR_ROLE_ID=<id>
# ─────────────────────────────────────────────────────────────
# Auto-VC
# ─────────────────────────────────────────────────────────────
SHAI_VC_NAME_PREFIX=DD Crew
SHAI_AUTO_VC_CLEANUP_DELAY=30
# ─────────────────────────────────────────────────────────────
# Threat weights
# ─────────────────────────────────────────────────────────────
SHAI_THREAT_W_KILL=0.30
SHAI_THREAT_W_DESTRUCTION=0.40
SHAI_THREAT_W_GROUP=0.20
SHAI_THREAT_W_SKILL=0.10
SHAI_THREAT_GROUP_THRESHOLD=3
SHAI_THREAT_MIN_SAMPLES_FOR_STATS=3
# ─────────────────────────────────────────────────────────────
# Toggles
# ─────────────────────────────────────────────────────────────
SHAI_NICK_NUDGE_LOOP_ENABLED=false
SHAI_USER_CARDS_CRON_ENABLED=true
# ─────────────────────────────────────────────────────────────
# SpicePay defaults
# ─────────────────────────────────────────────────────────────
SHAI_SPICEPAY_LSR_CUT_PERCENT=10
SHAI_SPICEPAY_BASE_WEIGHT=25
SHAI_SPICEPAY_CARRIER_BONUS=12.5
SHAI_SPICEPAY_CRAWLER_BONUS=12.5
# ─────────────────────────────────────────────────────────────
# Emojis (IDs)
# ─────────────────────────────────────────────────────────────
SHAI_EMOJI_MELANGE_ID=<id>
SHAI_EMOJI_SAND_ID=<id>
SHAI_EMOJI_CARRIER_CRAWLER_ID=<id>
# ─────────────────────────────────────────────────────────────
# Docs site (optional)
# ─────────────────────────────────────────────────────────────
SHAI_DOCS_HOST=0.0.0.0
SHAI_DOCS_PORT=8910
SHAI_DOCS_TITLE=ShaiWatcher Commands
SHAI_DOCS_SUPPORT_URL=
SHAI_DOCS_SUPPORT_LABEL=Buy me a ☕
# ─────────────────────────────────────────────────────────────
# Wrapper knobs (optional)
# ─────────────────────────────────────────────────────────────
PIP_INSTALL_REQUIREMENTS=1
WRAPPER_STOP_TIMEOUT=25
# Keep Docker base locale happy
LANG=C.UTF-8

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# Python
__pycache__/
*.pyc
.venv/
venv/
.env
# Local data & secrets (well mount these in Docker)
data/
data.json
data.json.bak
settings*.conf
NOTES.md
sanity/
.offline_data.json
dev/.env.production
dev/portainer_config.png
# Tools
wrapper/
wrapper/tools/

BIN
assets/docs/ShaiWatcher.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

568
assets/docs/cmd.html Normal file
View 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
s=s.replace(/```([\s\S]*?)```/g,(m,p1)=>"<pre><code>"+p1+"</code></pre>");
s=s.replace(/^###\s+(.*)$/gm,"<h3>$1</h3>");
s=s.replace(/^##\s+(.*)$/gm,"<h2>$1</h2>");
s=s.replace(/^#\s+(.*)$/gm,"<h1>$1</h1>");
s=s.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>");
s=s.replace(/\*(.+?)\*/g,"<em>$1</em>");
s=s.replace(/`([^`]+?)`/g,"<code>$1</code>");
s=s.replace(/^(\s*)-\s+(.+)$/gm,"$1<li>$2</li>");
s=s.replace(/(<li>.*<\/li>\n?)+/g,m=>"<ul>"+m+"</ul>");
s=s.replace(/^(?!<h\d|<ul>|<pre>|<li>)([^\n][^\n]*)$/gm,"<p>$1</p>");
return s; }
/* Link helpers */
function rowAnchor(r){ const cog=(r.cog||'nocog').toLowerCase();
let base=(r.name||'').toLowerCase().replace(/^\//,'').replace(/\s+/g,'-').replace(/\//g,'-'); return `${cog}-${base}`; }
function getFilterFromHash(){ const h=(location.hash||'#user').slice(1);
const parts=h.split('&').map(s=>s.trim()); const filt=parts.find(p=>p==='user'||p==='moderator'||p==='all')||'user';
let cmd=null; const cmdPart=parts.find(p=>p.startsWith('cmd=')); if(cmdPart) cmd=cmdPart.slice(4);
const sp=new URLSearchParams(location.search); const qcmd=sp.get('cmd'); if(!cmd&&qcmd) cmd=qcmd; return {filter:filt, cmd}; }
function buildLink(anchor){ const url=new URL(location.href); url.searchParams.set('cmd',anchor); url.hash=location.hash||'#user'; return url.toString(); }
function replaceURLFor(anchor){ try{ const url=new URL(location.href); url.searchParams.set('cmd',anchor); history.replaceState(null,'',url.toString()); }catch{} }
/* Copy helpers */
async function copyText(s){ try{ await navigator.clipboard.writeText(s); return true; }catch{ return false; } }
function showToast(msg='Copied!'){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'), 1100); }
const copyBackdrop=document.getElementById('copyBackdrop');
const copyPopover=document.getElementById('copyPopover');
const copyTextEl=document.getElementById('copyText');
const copyAction=document.getElementById('copyAction');
const copyClose=document.getElementById('copyClose');
function closeCopyPopover(){ copyBackdrop.classList.remove('open'); copyPopover.classList.remove('open'); }
copyBackdrop.addEventListener('click', closeCopyPopover);
copyClose.addEventListener('click', closeCopyPopover);
function openCopyPopover(text, anchorEl){
copyTextEl.textContent=text;
copyAction.onclick=async ()=>{ const ok=await copyText(text); if(ok){ closeCopyPopover(); showToast(); } };
// Position: prefer above; keep inside viewport
const r=anchorEl.getBoundingClientRect();
const pad=8, vw=innerWidth, vh=innerHeight;
copyPopover.style.visibility='hidden'; copyPopover.classList.add('open'); // measure
const pw=copyPopover.offsetWidth, ph=copyPopover.offsetHeight;
let left = Math.min(Math.max(pad, r.right - pw), vw - pw - pad);
let top = r.top - ph - pad;
if(top < (pad + 4)) top = Math.min(vh - ph - pad, r.bottom + pad);
copyPopover.style.left = `${left}px`;
copyPopover.style.top = `${top}px`;
copyPopover.style.visibility='visible';
copyBackdrop.classList.add('open');
}
/* Bottom sheet */
const fdb = document.getElementById('fullDetailsBackdrop');
const fds = document.getElementById('fullDetailsSheet');
const fdbtn = document.getElementById('fullDetailsClose');
const fdbody = document.getElementById('fullDetailsBody');
const fdtitle = document.getElementById('fullDetailsTitle');
function openFullDetails(title, html){
fdtitle.textContent = title || 'Command details';
fdbody.innerHTML = html || '<p>No extra details.</p>';
fdb.classList.add('open');
fds.classList.add('open');
}
function closeFullDetails(){ fdb.classList.remove('open'); fds.classList.remove('open'); }
fdb?.addEventListener('click', closeFullDetails);
fdbtn?.addEventListener('click', closeFullDetails);
/* App */
(function(){
if(!location.hash) location.hash='#user';
let data=(window.__DATA__||null);
const listEl=document.getElementById('list');
const qEl=document.getElementById('q');
const countsEl=document.getElementById('counts');
const detailsEl=document.getElementById('details');
const backdrop=document.getElementById('backdrop');
function shownName(r){ return (r.display_name||r.name||'').replace(/^\//,''); }
function helpSansMod(r){ return (r.help||'').replace(/^\s*\[(MOD|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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `<div class="usage">${esc}<button class="btn btn-icon copybtn" title="Copy usage" data-copy="${esc}">📋</button></div>`;
}
function wireUsageCopy(container){
container.querySelectorAll('[data-copy]').forEach(btn=>{
btn.addEventListener('click', async ev=>{
ev.stopPropagation();
const t=btn.getAttribute('data-copy').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&amp;/g,'&');
const ok=await copyText(t);
if(ok) showToast();
else openCopyPopover(t, btn);
});
});
}
function openDetails(r){
const anchor=rowAnchor(r);
replaceURLFor(anchor);
let html=`
<div class="name" style="margin-bottom:6px">
<span class="pill ${r.type}">${r.type}</span>
${r.moderator_only?'<span class="pill mod">mod</span>':''}
${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>

View File

@ -0,0 +1,8 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/avc_cleanup_now</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>Immediately deletes empty Auto-VC rooms that have been idle past the configured delay, then renumbers the rest.</p>
<div class="usage" style="margin-top:.5rem">/avc_cleanup_now</div>
<p style="margin-top:.6rem"><small>
Replies “Cleanup pass complete.” on success.
</small></p>

View File

@ -0,0 +1,23 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<ul>
<li>Scans the target category for <b>managed</b> Auto-VC rooms.</li>
<li>Deletes rooms that have been <b>empty</b> for at least the configured <b>delay</b> (seconds).</li>
<li>Renumbers remaining rooms to a clean sequence (<code>Prefix 1..N</code>).</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
<ul>
<li>After a busy period to tidy up stale empties.</li>
<li>If numbering looks off and you want a quick clean + renumber in one go.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
<ul>
<li><b>Moderator-only.</b></li>
<li>Bot needs: <b>Manage Channels</b>, <b>Connect</b>, and <b>Move Members</b> in the target category to function smoothly.</li>
<li>Safe to run anytime. The bot pauses the background sweeper while this runs to avoid conflicts.</li>
</ul>

View File

@ -0,0 +1,8 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/avc_renumber</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>Force a rename of all managed rooms to a clean numeric order without deleting anything.</p>
<div class="usage" style="margin-top:.5rem">/avc_renumber</div>
<p style="margin-top:.6rem"><small>
Replies “Renumbered.” on success.
</small></p>

View File

@ -0,0 +1,19 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<p>Renames tracked rooms to <code>{prefix} 1</code>, <code>{prefix} 2</code>, … in creation order. No rooms are deleted.</p>
<hr>
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
<ul>
<li>Numbering drifted after manual edits or channel deletions.</li>
<li>You changed the name prefix and want all rooms aligned quickly.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
<ul>
<li><b>Moderator-only.</b></li>
<li>Bot needs <b>Manage Channels</b> to rename rooms.</li>
<li>Safe to run anytime; the bot sequences this against its background sweeper.</li>
</ul>

View File

@ -0,0 +1,8 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/avc_status</code></h2>
<p>Shows current Auto-VC setup and the list of managed rooms.</p>
<div class="usage" style="margin-top:.5rem">/avc_status</div>
<p style="margin-top:.6rem"><small>
Output includes trigger channel, target category, name prefix, cleanup delay, and each tracked room with its current state.
</small></p>

View File

@ -0,0 +1,22 @@
<h2 style="margin:0 0 .5rem 0">What it shows</h2>
<ul>
<li><b>Trigger</b> — the voice channel that spawns new rooms.</li>
<li><b>Category</b> — where new rooms are created.</li>
<li><b>Prefix</b> — base name (e.g. <code>Room</code><code>Room 1</code>, <code>Room 2</code>…).</li>
<li><b>Delay</b> — seconds a room must stay empty before cleanup can delete it.</li>
<li><b>Tracked rooms</b> — each rooms name and whether its empty or how many are inside. If a room is empty, an “idle Ns” timer may appear.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Example output</h2>
<pre style="white-space:pre-wrap;margin:.25rem 0 0">
Auto-VC status:
Trigger: &lt;#1234567890&gt; | Category: &lt;#2345678901&gt; | Prefix: `Room` | Delay: 30s
- #1: Room 1 — 3 inside
- #2: Room 2 — empty | idle 12s
</pre>
<p style="margin-top:.75rem"><small>
This command is read-only and safe to run anytime.
</small></p>

View File

@ -0,0 +1,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 &lt;action&gt; [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>

View 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 &lt;action&gt; [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 its 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 its 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 servers 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>

View File

@ -0,0 +1,8 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/clear_nick_reviews</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>Delete all <b>pending</b> nickname review records for this server.</p>
<div class="usage" style="margin-top:.5rem">/clear_nick_reviews</div>
<p style="margin-top:.6rem"><small>
Replies with how many pending entries were removed (ephemeral).
</small></p>

View File

@ -0,0 +1,21 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<ul>
<li>Removes every <b>pending</b> entry in the nickname review queue for this server.</li>
<li>Does <u>not</u> touch approved/rejected history or verification flags.</li>
<li>Useful when the queue got noisy or stuck after a busy period.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Result</h2>
<p>
Shows: <code>Cleared N pending nickname review(s).</code> (ephemeral)
</p>
<hr>
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
<ul>
<li><b>Moderator-only.</b></li>
<li>Writes a short entry to the mod log (if configured).</li>
</ul>

View File

@ -0,0 +1,8 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/recreate_nick_review</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>Recreate a <b>single</b> users missing pending review.</p>
<div class="usage" style="margin-top:.5rem">/recreate_nick_review <b>user:</b> @Member</div>
<p style="margin-top:.6rem"><small>
If theyre already verified or already have a pending review, youll get a short explanation instead (ephemeral).
</small></p>

View File

@ -0,0 +1,25 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<ul>
<li>Checks the selected member:</li>
<ul>
<li>If <b>already verified</b> → no action.</li>
<li>If a <b>pending review exists</b> → no action.</li>
<li>Else → marks them as claimed (if needed) and opens a new pending review in the mod channel.</li>
</ul>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
<ul>
<li>One user fell through the cracks and needs a review opened again.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
<ul>
<li><b>Moderator-only.</b></li>
<li>Logs to the mod log (if configured) with who triggered it.</li>
<li>Reply is ephemeral.</li>
</ul>

View File

@ -0,0 +1,8 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/recreate_nick_reviews</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>Scan the server and recreate any <b>missing</b> pending reviews for users who claimed but never got a review opened.</p>
<div class="usage" style="margin-top:.5rem">/recreate_nick_reviews</div>
<p style="margin-top:.6rem"><small>
Replies: <code>Recreated X review(s); skipped Y.</code> (ephemeral)
</small></p>

View File

@ -0,0 +1,22 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<ul>
<li>Looks at users who <b>claimed</b> nickname compliance but are <u>not verified</u> and have <u>no pending review</u>.</li>
<li>Opens a fresh review in the mod channel for each such user (one per user, no duplicates).</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
<ul>
<li>After a restart/outage where some pending reviews may have gone missing.</li>
<li>Whenever you see “claimed” users without an open review.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Permissions & notes</h2>
<ul>
<li><b>Moderator-only.</b></li>
<li>Posts a summary to the mod log (if configured).</li>
<li>Only processes this server; other servers are unaffected.</li>
</ul>

View File

@ -0,0 +1,17 @@
<h2>/pirate_cards_rebuild</h2>
<p><strong>Rebuild or update pirate cards</strong> for every known pirate in the configured channel.</p>
<h3>Usage</h3>
<pre>/pirate_cards_rebuild</pre>
<ul>
<li><strong>Moderator-only</strong>.</li>
<li>Run in a server channel. The reply is ephemeral.</li>
</ul>
<h3>What it does</h3>
<ul>
<li>Creates or updates one embed “card” per pirate in the pirates list channel.</li>
<li>Each card shows Account, Threat %, grouping/destructive behavior buckets, encounters count, and last seen date.</li>
<li>Card color reflects threat (unknown → gray; low → green; high → red).</li>
</ul>

View File

@ -0,0 +1,44 @@
<h1>/pirate_cards_rebuild — Rebuild all pirate cards</h1>
<p>Runs a full pass that makes sure each verified pirate has a fresh, accurate card in the configured channel.</p>
<h2>Access</h2>
<ul>
<li><strong>Moderator-only</strong>.</li>
<li>Must be used in a server channel (not DMs). The confirmation is sent <em>ephemerally</em>.</li>
</ul>
<h2>What it updates</h2>
<ul>
<li><strong>Card fields</strong>: Account name, Threat %, “In groups” bucket, “Destructive” bucket, total encounters, and last encounter date (UTC).</li>
<li><strong>Colors</strong>: Unknown samples → dark gray. Otherwise a green→red gradient based on Threat %.</li>
<li><strong>Safety</strong>: User-provided names are escaped and mentions are neutralized (no pings).</li>
</ul>
<h2>Behavior</h2>
<ol>
<li>For each pirate in the internal list:
<ul>
<li>If their card already exists, it edits the embed in place.</li>
<li>If it cant find the message, it posts a new card and records the message ID.</li>
</ul>
</li>
<li>Works serially per guild to avoid race conditions.</li>
</ol>
<h2>Output</h2>
<p>Ephemeral summary, e.g.:</p>
<pre>Rebuilt/updated 27 pirate cards.</pre>
<h2>Setup notes</h2>
<ul>
<li>Uses <code>pirates_list_channel_id</code> for the destination channel.</li>
<li>Threat %, group/destructive buckets, and last seen are computed from stored encounters.</li>
<li>Bucket thresholds respect your server settings (e.g., <code>threat_group_threshold</code>, <code>threat_min_samples_for_stats</code>).</li>
</ul>
<h2>When to run it</h2>
<ul>
<li>After importing or migrating data.</li>
<li>After a batch of new pirates is approved.</li>
<li>Any time the cards look out of date.</li>
</ul>

View File

@ -0,0 +1,16 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/edit_pirate</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>Edit an existing pirate entry. Use this when a player changes nickname or account tag.</p>
<ul>
<li><b>Current account</b> — the exact existing account in the list (<code>Name#12345</code>).</li>
<li><b>New in-game nickname</b> — optional.</li>
<li><b>New account</b> — optional, must be <code>Name#12345</code>.</li>
</ul>
<div class="usage" style="margin-top:.75rem">/edit_pirate</div>
<p style="margin-top:.6rem"><small>
You must provide <b>at least one</b> of: new nickname or new account.<br>
The account format is strict: <code>#</code> + <b>five digits</b>. Duplicate accounts are blocked.
</small></p>

View File

@ -0,0 +1,24 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<p>
Updates a pirate record thats already approved. Typical cases: the player renamed their character,
or moved to a new account tag.
</p>
<hr>
<h2 style="margin:.75rem 0 .5rem">Form fields</h2>
<ul>
<li><b>Current account</b> — must match exactly whats in the list (e.g., <code>SomeUser#12345</code>).</li>
<li><b>New in-game nickname</b> — optional; leave empty to keep the current one.</li>
<li><b>New account</b> — optional; must be <code>Name#12345</code>. If the new value already exists on another pirate, its rejected.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Rules & feedback</h2>
<ul>
<li><b>Moderator-only.</b></li>
<li>You must change at least one thing (nickname or account).</li>
<li>On success youll see “✅ Pirate updated.” (ephemeral) and the public list refreshes.</li>
<li>A short mod-log entry is posted for audit (“✏️ Edited pirate …”).</li>
</ul>

View File

@ -0,0 +1,21 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/encounter</code></h2>
<p>Log a pirate encounter so threat scores stay fresh. Takes ~10 seconds.</p>
<ul>
<li><b>Pirate</b> — account (<code>Name#12345</code>) or exact character name.<br>
<small>Account is best. Character name must be unambiguous.</small></li>
<li><b>Group size</b> — integer ≥ 1 (how many pirates in that group, including them).</li>
<li><b>Kills</b> — integer ≥ 0 (0 if none/unknown).</li>
<li><b>Destructive?</b> — yes/no (e.g., base or ornithopter destroyed).</li>
<li><b>Perceived Skill</b> — 05 (0 = unknown).</li>
</ul>
<p><b>Rate limit:</b> you can report the <i>same</i> pirate once every <b>10 minutes</b>.</p>
<div style="margin-top:.75rem">
<div class="usage">/encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4</div>
<div class="usage" style="margin-top:.4rem">/encounter SandStalker · group 5 · kills 0 · destructive no · skill 2</div>
</div>
<p style="margin-top:.75rem"><small>After you submit, youll get a private “Encounter recorded” message and the pirate list updates shortly after.</small></p>

View File

@ -0,0 +1,75 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<p>
<code>/encounter</code> adds a single encounter to the pirates history. These entries feed the
threat score so the pirate list reflects how dangerous someone actually is right now.
</p>
<hr>
<h2 style="margin:.75rem 0 .5rem">Form fields</h2>
<ul>
<li><b>Pirate (name or account)</b><br>
Best: <code>Name#12345</code>. If you only know the character name, it must match exactly and not be ambiguous.
</li>
<li><b>Group size</b><br>
Integer <b>≥ 1</b>. How many pirates were in their group, including the reported pirate.
</li>
<li><b>Kills</b><br>
Integer <b>≥ 0</b>. Use 0 for none/unknown.
</li>
<li><b>Destructive?</b><br>
<code>yes</code>/<code>no</code>. “Yes” if a base/ornithopter was destroyed, etc.
</li>
<li><b>Perceived Skill</b><br>
<b>05</b>. Use 0 if youre unsure. 5 = cracked aim/sweaty movement, 12 = casual.
</li>
</ul>
<div class="usage" style="margin:.6rem 0 0">
/encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4
</div>
<hr>
<h2 style="margin:.75rem 0 .5rem">Validation & messages</h2>
<ul>
<li>If the account format is wrong, youll be told what to fix (<code>Name#12345</code> ending with five digits).</li>
<li>Character names must be an exact match. If multiple pirates share that name, youll be asked to use the account.</li>
<li><b>Rate limit:</b> one report per user per pirate every <b>10 minutes</b>.</li>
<li>Youll see a private <b>“Encounter recorded”</b> confirmation on success.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">How threat is calculated (short)</h2>
<p>
Each encounter contributes to a weighted score (0100):
</p>
<ul>
<li><b>Kills</b>: higher if the pirate gets kills.</li>
<li><b>Destructive</b>: higher if they destroy stuff.</li>
<li><b>Group size</b>: higher if they roll with <code>group ≥ T</code> (T comes from config).</li>
<li><b>Skill</b>: your 05 rating normalized and averaged across encounters.</li>
</ul>
<p>
The weights are configurable (defaults: Kill 0.35, Destruction 0.30, Group 0.20, Skill 0.15). After you submit,
the bot recomputes the pirates <b>threat level</b> and <b>encounter count</b>, then refreshes the list.
</p>
<hr>
<h2 style="margin:.75rem 0 .5rem">Tips</h2>
<ul>
<li>Use the account (<code>Name#12345</code>) whenever possible — it guarantees the report hits the right person.</li>
<li>If you cant tell skill, leave it at 0. Thats fine.</li>
<li>Group size includes the pirate youre reporting.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Common errors</h2>
<ul>
<li><b>“No such pirate registered.”</b> — Ask a mod to add them, or submit a <code>/report</code> first.</li>
<li><b>“Character name is ambiguous.”</b> — Use the account tag (<code>Name#12345</code>).</li>
<li><b>“You can only report the same pirate once every 10 minutes.”</b> — Wait a bit and try again.</li>
</ul>

View File

@ -0,0 +1,12 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/encounters_migrate_ids</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>
Converts old encounter records stored by <b>character name</b> to the
canonical <b>account</b> (<code>Name#12345</code>).
</p>
<div class="usage" style="margin-top:.5rem">/encounters_migrate_ids</div>
<p style="margin-top:.6rem"><small>
Useful after adding pirates where past encounters referenced only nicknames.
</small></p>

View File

@ -0,0 +1,27 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<p>
Scans stored encounters and rewrites any identifier that is a <b>character name</b> to the correct
<b>account</b> for that pirate. Keeps data consistent for threat calculations.
</p>
<hr>
<h2 style="margin:.75rem 0 .5rem">How matching works</h2>
<ul>
<li>If the encounter already uses an account (<code>#</code> + five digits), its counted as “already accounts”.</li>
<li>If the character name maps to exactly one pirate, its updated to that pirates account.</li>
<li>If theres no pirate with that name, its counted as “not found”.</li>
<li>If multiple pirates share the same nickname, its “ambiguous” and skipped.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Result</h2>
<ul>
<li>Replies with a compact summary (updated / already / ambiguous / not found).</li>
<li>Refreshes the public pirate list afterwards.</li>
</ul>
<p style="margin-top:.6rem"><small>
<b>Moderator-only.</b>
</small></p>

View File

@ -0,0 +1,9 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/remove_pirate</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>Remove a pirate from the approved list by <b>account</b>.</p>
<div class="usage" style="margin-top:.5rem">/remove_pirate account_name:&nbsp;<code>Name#12345</code></div>
<p style="margin-top:.6rem"><small>
Account format must be <code>Name#12345</code>. If the account isnt found, youll get “Pirate not found.”
</small></p>

View File

@ -0,0 +1,19 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<p>Deletes the matching pirate record from the approved list.</p>
<hr>
<h2 style="margin:.75rem 0 .5rem">Parameters</h2>
<ul>
<li><b>account_name</b> — exact account tag (<code>Name#12345</code>).</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Behavior & messages</h2>
<ul>
<li><b>Moderator-only.</b></li>
<li>If no match: “Pirate not found.”</li>
<li>On success: “Removed.” (ephemeral via slash).</li>
<li>Mod-log note is posted (“🗑️ Removed pirate …”) and the public list refreshes.</li>
</ul>

View File

@ -0,0 +1,24 @@
<h3 style="margin:0 0 4px 0;">What it does</h3>
<p style="margin:0 0 8px 0;">
Sends a player to the <strong>Pirate Review Queue</strong>. Mods get a card with ✅/❌.
If you include a Discord media link, it will preview for them.
</p>
<h4 style="margin:10px 0 4px 0;">Quick use — <code>/report</code></h4>
<ul style="margin:0 0 8px 18px;">
<li><strong>In-game nickname</strong> e.g. <code>SandStalker</code></li>
<li><strong>Account</strong> must be <code>Name#12345</code> (five digits)</li>
<li><strong>Proof</strong> (optional, recommended) direct Discord CDN link
(<code>cdn.discordapp.com</code> / <code>media.discordapp.net</code>)</li>
</ul>
<h4 style="margin:10px 0 4px 0;">Limits</h4>
<ul style="margin:0 0 6px 18px;">
<li>1 submission per user every 60s</li>
<li>No duplicates (pending or already approved)</li>
<li>Non-Discord media links are blocked</li>
</ul>
<p style="margin:6px 0 0 0; color:#9ca3af;">
Tip: Images show inside the card; videos stay as a link so the inline player works.
</p>

View File

@ -0,0 +1,48 @@
<h2 style="margin-top:0;">Submit a pirate report</h2>
<p>
Use <code>/report</code> to send a player to the <strong>Pirate Review Queue</strong>.
Mods see a compact card with your info and can approve or reject with one click.
</p>
<h3>Form fields</h3>
<ul>
<li><strong>In-game nickname</strong><br>
The name they use in game. Example: <code>SandStalker</code></li>
<li><strong>Account (Name#12345)</strong><br>
Must end with <code>#</code> + <strong>five digits</strong>. Example: <code>SomeUser#12345</code><br>
If you only know the nickname, try to grab the account too it prevents ambiguity.</li>
<li><strong>Proof (Discord media URL)</strong> optional but highly encouraged<br>
Use a direct Discord CDN link so it previews: <code>https://cdn.discordapp.com/…</code> or
<code>https://media.discordapp.net/…</code><br>
Allowed: <em>png, jpg, jpeg, gif, webp, mp4, webm, mov</em>.<br>
How to get it: open the media in Discord → “Open in browser” → copy the address.</li>
</ul>
<h3>What happens next</h3>
<ol>
<li>You get a short “thanks” message in the channel.</li>
<li>Mods receive an embed with ✅/❌. If you added an image, it shows inside the card.
If its a video, the URL is kept above the card so the inline player works.</li>
<li>A “Jump to message” button lets mods see your original context quickly.</li>
<li>When a mod decides, the card updates to show <em>Approved/Rejected</em> with who and when.
Your small ack message in the channel is edited to reflect the result.</li>
<li>If approved, the player is added to the pirate list (threat level starts at 0).</li>
</ol>
<h3>Rules & common errors</h3>
<ul>
<li><strong>Account format</strong> must be <code>Name#12345</code>. Five digits no spaces at the end.</li>
<li><strong>Proof link</strong> must be a Discord media URL. Non-Discord links are blocked.</li>
<li><strong>Duplicates</strong> are blocked (already pending or already approved).</li>
<li><strong>Rate limit</strong>: one report per user every 60 seconds.</li>
</ul>
<p style="color:#9ca3af">
Typical messages: “Invalid account format”, “URL must be a Discord media link”, “A report for this player is already pending”.
</p>
<h3>Related actions</h3>
<ul>
<li><code>/encounter</code> log a new encounter with a known pirate (updates threat level over time).</li>
<li><code>/edit_pirate</code> (mods) update a pirates nickname/account.</li>
<li><code>remove_pirate</code> (mods, hybrid) remove an entry.</li>
</ul>

View File

@ -0,0 +1,9 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/pirates_list_refresh</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>Rebuild the compact pirates list in the configured channel. Use this after adding/removing pirates or when encounter stats change.</p>
<div class="usage" style="margin-top:.5rem">/pirates_list_refresh</div>
<ul style="margin:.6rem 0 0 1rem">
<li>Replies: <code>Pirates list refreshed.</code> (ephemeral)</li>
<li>Runs per-guild cooldown: 10s</li>
</ul>

View File

@ -0,0 +1,55 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<ul>
<li>Builds a compact, alphabetized list of verified pirates and posts it in the configured list channel.</li>
<li>Entries are chunked to stay under Discords 2000-character limit; old chunks are edited or deleted as needed.</li>
<li>No one gets pinged — all content is sanitized to avoid accidental mentions.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Entry format</h2>
<pre style="margin:.5rem 0;white-space:pre-wrap">
- <b>Character</b> (<i>Account#12345</i>) [Threat%]
- In group: <i>bucket</i>. Destructive: <i>bucket</i>. Encounters: N. Last: &lt;t:UNIX:R&gt;
</pre>
<ul>
<li><b>Threat%</b> reflects combined encounter signals (kills, destructive behavior, group size, skill).</li>
<li><b>In group / Destructive</b> use buckets: <code>unknown</code>, <code>never</code>, <code>rarely</code>, <code>sometimes</code>, <code>often</code>, <code>always</code>.</li>
<li><b>Last</b> shows the most recent encounter as a relative timestamp.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">When to use</h2>
<ul>
<li>After a new pirate is approved or removed.</li>
<li>After logging encounters that meaningfully change threat or buckets.</li>
<li>When the list looks stale or out of order.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Behavior & notes</h2>
<ul>
<li>Sorts by character name, then account.</li>
<li>If there are no pirates yet, shows a single placeholder line.</li>
<li>If the list channel isnt configured or cant be found, nothing is posted (check your config).</li>
<li>Per-guild cooldown: 10 seconds (prevents spam).</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Config that affects output</h2>
<ul>
<li><code>pirates_list_channel_id</code> — where the list is posted.</li>
<li><code>threat_group_threshold</code> — minimum party size that counts as “in group”.</li>
<li><code>threat_min_samples_for_stats</code> — encounters needed before buckets stop showing <code>unknown</code>.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Permissions</h2>
<ul>
<li><b>Moderator-only.</b></li>
<li>Bot needs permission to read and write in the list channel.</li>
</ul>

View File

@ -0,0 +1,10 @@
<h2 style="margin:0 0 .5rem 0">Quick usage — <code>/power restart</code> <small style="opacity:.8">[Moderator]</small></h2>
<p>Safely restarts the bot after logging a clear reason to the modlog.</p>
<div class="usage" style="margin-top:.5rem">/power restart reason:"Restarting to enable new permissions sync after config change"</div>
<ul style="margin:.6rem 0 0 1rem">
<li>Requires a descriptive reason (see details).</li>
<li>Replies: <code>🔁 Restart requested — logging to modlog and restarting…</code> (ephemeral)</li>
<li>Bot restarts a couple seconds after the reply.</li>
</ul>

View File

@ -0,0 +1,61 @@
<h2 style="margin:0 0 .5rem 0">What it does</h2>
<ul>
<li>Checks youre a moderator, validates your reason, and posts a restart entry to the modlog (who, when, version, reason).</li>
<li>Closes the bot cleanly and exits; your process manager/container brings it back up.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Reason requirements</h2>
<p>The <code>reason</code> must be specific enough to audit later:</p>
<ul>
<li>At least <b>20 characters</b></li>
<li>At least <b>4 words</b></li>
<li>At least <b>3 words</b> with length ≥ 3</li>
<li>Rejects vague phrases like: <code>stuck</code>, <code>idk</code>, <code>don't know</code>, <code>unknown</code>, <code>?</code>,
<code>lag</code>, <code>restart</code>, <code>restarting</code>, <code>update</code>, <code>updating</code>,
<code>bug</code>, <code>crash</code>, <code>crashed</code></li>
</ul>
<h3 style="margin:.75rem 0 .4rem">Examples</h3>
<div style="display:grid; gap:.5rem">
<div>
<div style="font-weight:600">✅ Good</div>
<pre style="margin:.25rem 0;white-space:pre-wrap">/power restart reason:"Reloading cogs after changing threat weights and enabling nick loop; avoids inconsistent state."</pre>
</div>
<div>
<div style="font-weight:600">❌ Bad</div>
<pre style="margin:.25rem 0;white-space:pre-wrap">/power restart reason:"update"</pre>
</div>
</div>
<hr>
<h2 style="margin:.75rem 0 .5rem">What youll see</h2>
<ul>
<li>Ephemeral confirmation in chat.</li>
<li>Modlog post similar to:
<pre style="margin:.4rem 0;white-space:pre-wrap">🔁 Bot Restart Requested
By: @YourName
When: 2025-01-01 12:34 UTC
Running version: vX.Y.Z
Reason: Reloading cogs after threat weight change…</pre>
</li>
<li>Bot goes offline briefly, then comes back once the host restarts it.</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Config that affects it</h2>
<ul>
<li><code>modlog_channel_id</code> — where the restart entry is posted. If not set, it falls back to server logs only.</li>
<li><code>home_guild_id</code> — where the <code>/power</code> group is registered (home-guild only vs global).</li>
</ul>
<hr>
<h2 style="margin:.75rem 0 .5rem">Permissions</h2>
<ul>
<li><b>Moderator-only.</b> Non-mods are blocked with an ephemeral message.</li>
<li>Bot just needs permission to send messages in the modlog channel.</li>
</ul>

View File

@ -0,0 +1,18 @@
<h2>/nick_same</h2>
<p><strong>Nick matches my in-game name.</strong> Tells mods your current <em>server nickname</em> (or global display name if you havent set one) already matches your in-game name, and opens a quick review.</p>
<h3>Usage</h3>
<pre>/nick_same</pre>
<ul>
<li>Youll get an <em>ephemeral</em> “thanks” message.</li>
<li>Moderators get a review with ✅ / ❌ in the mod channel.</li>
<li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + this nickname claim (pending or verified).</li>
</ul>
<h3>Tips</h3>
<ul>
<li>Run it in the server (not in DMs).</li>
<li>If you recently changed your nick, this will trigger a re-review.</li>
<li>Alternative: react ✅ on the “Nickname” rules message to claim the same thing.</li>
</ul>

View File

@ -0,0 +1,38 @@
<h1>/nick_same — Nickname claim</h1>
<p>Use this when your <strong>server nickname</strong> already matches your in-game character name. It opens a moderator review so you can get (or keep) access without typing anything else.</p>
<h2>What it does</h2>
<ul>
<li>Marks your nickname as <em>claimed</em> and opens exactly <strong>one</strong> pending review for moderators.</li>
<li>Youll see a short <em>ephemeral</em> confirmation in the channel.</li>
<li>Moderators get a message in the mod channel with ✅ Approve / ❌ Reject.</li>
<li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + Nickname <em>claimed</em> (pending or verified).</li>
</ul>
<h2>How to use</h2>
<pre>/nick_same</pre>
<ul>
<li>Run it in the server, any channel where you can use slash commands.</li>
<li>No extra arguments. If your display name already matches the game name, thats enough.</li>
</ul>
<h2>Typical flow</h2>
<ol>
<li><strong>You</strong> run <code>/nick_same</code>. The bot replies “Thanks — your nickname claim was sent for moderator review.”</li>
<li><strong>Mods</strong> see a review card with your previous nick (if known) and the current one.</li>
<li>They press ✅ to verify or ❌ to reject. You keep (or lose) Full Access depending on Rules/Engagement and the result.</li>
</ol>
<h2>Good to know</h2>
<ul>
<li>If a verified user changes their nickname later, verification is <strong>automatically revoked</strong>. Just run <code>/nick_same</code> again (or react ✅ on the nickname message) to re-claim and re-review.</li>
<li>The command is idempotent: if a review is already pending, it wont open duplicates.</li>
<li>Alternative path: reacting ✅ on the <em>Nickname</em> rules message triggers the same review and access checks.</li>
</ul>
<h2>Troubleshooting</h2>
<ul>
<li><strong>No response?</strong> Make sure you used it inside the server (not DMs) and that slash commands are allowed in the channel.</li>
<li><strong>Still no Full Access?</strong> You need all three: Rules ✅, Engagement ✅, and a nickname claim (pending or verified). Missing any one will remove the role.</li>
<li><strong>Mods didnt get a review?</strong> Ping a moderator; the mod channel might be misconfigured.</li>
</ul>

View File

@ -0,0 +1,25 @@
<h2>/spicepay</h2>
<p><strong>Open the Spice Pay wizard.</strong> Step through participants, roles, payout type (Sand or Melange), and weighting. Shows a live preview and lets you post the result.</p>
<h3>Usage</h3>
<pre>/spicepay [participants] [force_new]</pre>
<ul>
<li><code>participants</code> (optional): total people, incl. owners (125).</li>
<li><code>force_new</code> (optional): start fresh if you already have a session.</li>
</ul>
<h3>Quick flow</h3>
<ol>
<li>Enter the total yield and participant count.</li>
<li>Add/edit each participant (name, active %, owner roles).</li>
<li>Toggle payout: Sand ⟷ Melange (set refinery yield for Melange).</li>
<li>Adjust weighting factors or use a preset.</li>
<li>Finish → preview → Post to channel.</li>
</ol>
<h3>Notes</h3>
<ul>
<li>0% active = owner-only (gets owner perks but didnt actively join).</li>
<li>Melange: exactly one <em>Refiner</em> owner is required.</li>
<li>Rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>
</ul>

View File

@ -0,0 +1,72 @@
<h1>/spicepay — Guided payout</h1>
<p>Calculate a fair split for a spice run. The wizard keeps things simple and transparent for the team.</p>
<h2>Start</h2>
<pre>/spicepay [participants] [force_new]</pre>
<ul>
<li>If <code>participants</code> is omitted, you set it in the first modal (125).</li>
<li>Use <code>force_new: true</code> to discard an existing session and start clean.</li>
</ul>
<h2>Setup modal</h2>
<ul>
<li><strong>Total spice yield (sand)</strong> — integer ≥ 0.</li>
<li><strong>Participants</strong> — include <em>owners</em> (Refiner/Carrier/Crawler). People only; vehicles/refinery are <em>owned</em> by people.</li>
</ul>
<h2>Editing participants</h2>
<p>For each slot:</p>
<ul>
<li><strong>Name</strong> — free text (mention text is fine).</li>
<li><strong>Active %</strong> — 0100. <em>0% = owner-only</em> (didnt actively join).</li>
<li><strong>Owner of</strong> (optional) — any of: <code>refiner</code>, <code>carrier</code>, <code>crawler</code>. Flexible input (e.g. “lsr”, “refinery”, “car”, “cr”).</li>
</ul>
<p>Use <em>Add / Edit participant</em>, <em>Previous/Next</em> to navigate. The preview shows filled vs. empty slots and highlights “owner-only”.</p>
<h2>Payout type</h2>
<ul>
<li><strong>Sand</strong> — no refinery cut; 0 or 1 Refiner owner allowed.</li>
<li><strong>Melange</strong><em>requires exactly one</em> Refiner owner. First a refinery cut % is taken and paid to the Refiner, then the rest is split.</li>
</ul>
<p>When switching to <strong>Melange</strong>, set the refinery yield (integer) in the modal.</p>
<h2>Weighting (the math, simplified)</h2>
<ul>
<li>Each person gets a <em>weight</em>: <strong>Base × Active%</strong> + bonuses for owning <strong>Carrier</strong>/<strong>Crawler</strong>.</li>
<li>We split the pot <em>proportionally</em> by weights (theyre normalized; only ratios matter).</li>
<li>Melange only: take <strong>Refinery cut %</strong> first → Refiner. Any rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>
</ul>
<h3>Controls</h3>
<ul>
<li><em>Toggle payout: Sand/Melange</em> — switches mode (prompts for refinery yield on Melange).</li>
<li><em>Adjust weighting factors</em> — change:
<ul>
<li><strong>Base × active %</strong> (default <em>server setting</em>)</li>
<li><strong>Carrier bonus</strong> ( + )</li>
<li><strong>Crawler bonus</strong> ( + )</li>
<li><strong>Refinery cut %</strong> (Melange only)</li>
</ul>
</li>
<li><em>Weights help</em> — short explanation inline.</li>
<li><em>Presets</em> — Owner-heavy / Participation-heavy / Fair (server defaults) / Even split.</li>
<li><em>Save as my defaults</em> — stores your weights for future runs (per user).</li>
<li><em>Finish</em> — shows a neat preview (table) and lets you <em>Post to channel</em>.</li>
</ul>
<h2>Validation & limits</h2>
<ul>
<li>Participants: 125.</li>
<li>Active %: 0100.</li>
<li>Melange: exactly one Refiner owner required; Sand: 0 or 1 allowed.</li>
<li>No duplicate names (duplicates are auto-disambiguated in preview).</li>
</ul>
<h2>Posting</h2>
<p>The post includes:</p>
<ul>
<li>Header (Sand or Melange) with the chosen emoji.</li>
<li>Weighting summary (Base, bonuses, and refinery cut if Melange).</li>
<li>A clean monospace table: Name · Active% · Owner of/Role · Amount.</li>
</ul>
<p><em>Footnote:</em> “0% = owner only”.</p>

View File

@ -0,0 +1,10 @@
<h2>/spicepay_cancel</h2>
<p><strong>Cancel your active Spice Pay session.</strong> Clears everything for you so you can start fresh.</p>
<h3>Usage</h3>
<pre>/spicepay_cancel</pre>
<ul>
<li>Removes your in-memory session (does not post anything).</li>
<li>Run <code>/spicepay</code> again to start over.</li>
</ul>

View File

@ -0,0 +1,11 @@
<h1>/spicepay_cancel — Cancel session</h1>
<p>Discards your current wizard state. Handy if you mis-entered totals or want to restructure the roster.</p>
<h2>What it does</h2>
<ul>
<li>Deletes your session (you + this server).</li>
<li>Nothing is posted publicly.</li>
</ul>
<h2>Next steps</h2>
<p>Run <code>/spicepay</code> to start again. You can optionally pass <code>participants</code> or just set them in the first modal.</p>

View File

@ -0,0 +1,10 @@
<h2>/spicepay_config</h2>
<p><strong>Show server SpicePay weights.</strong> Read-only view of the defaults the wizard uses.</p>
<h3>Usage</h3>
<pre>/spicepay_config</pre>
<ul>
<li>Displays: Refinery cut %, Base × active %, Carrier bonus, Crawler bonus.</li>
<li>Change via ENV/INI; restart the bot to apply.</li>
</ul>

View File

@ -0,0 +1,19 @@
<h1>/spicepay_config — Server weights</h1>
<p>Shows the current default weighting used by the Spice Pay wizard.</p>
<h2>Fields</h2>
<ul>
<li><strong>Refinery cut %</strong> (Melange only) — percent taken first and paid to the Refiner owner.</li>
<li><strong>Base × active %</strong> — the core share, scaled by each players active participation.</li>
<li><strong>Carrier bonus</strong> — flat weight added if the person owns the carrier.</li>
<li><strong>Crawler bonus</strong> — flat weight added if the person owns the crawler.</li>
</ul>
<h2>Where to change</h2>
<p>Configure via environment variables or your INI, then restart the bot. The wizards <em>Adjust weighting factors</em> lets users override per-run; those dont change the server defaults.</p>
<h2>Tips</h2>
<ul>
<li>Use <em>Presets</em> in the wizard to quickly switch philosophy (owner-heavy, participation-heavy, etc.).</li>
<li>Users can <em>Save as my defaults</em> to keep their personal weights for future runs.</li>
</ul>

View File

@ -0,0 +1,10 @@
<h2>/spicepay_resume</h2>
<p><strong>Reopen your active Spice Pay session.</strong> Useful if you closed the wizard message by accident.</p>
<h3>Usage</h3>
<pre>/spicepay_resume</pre>
<ul>
<li>If a session exists, the wizard view is shown again.</li>
<li>If not, youll be told to run <code>/spicepay</code>.</li>
</ul>

View File

@ -0,0 +1,14 @@
<h1>/spicepay_resume — Resume wizard</h1>
<p>Brings back the current session UI (if you already started one with <code>/spicepay</code>).</p>
<h2>When to use</h2>
<ul>
<li>You dismissed the ephemeral wizard message and want it back.</li>
<li>Youre mid-entry and dont want to start over.</li>
</ul>
<h2>Behavior</h2>
<ul>
<li>If a session exists (keyed to you + this server), it re-renders the progress view.</li>
<li>If none exists, you get a friendly nudge to start a new one.</li>
</ul>

View File

@ -0,0 +1,18 @@
<h2>/usercards_rescan</h2>
<p><strong>Re-check everyone and refresh the user cards.</strong> Also repairs Roles/RoE/nickname claims from the live reaction messages, and re-opens any missing nickname reviews.</p>
<h3>Usage</h3>
<pre>/usercards_rescan</pre>
<ul>
<li><strong>Moderator-only</strong> (requires <em>Manage Server</em>).</li>
<li>Runs in the server; reply is ephemeral with a short summary.</li>
</ul>
<h3>What it does</h3>
<ul>
<li>Reconciles from the configured Rules / RoE / Nickname reaction messages.</li>
<li>Grants/removes the Rules &amp; RoE roles to match reactions.</li>
<li>For Nickname: opens a pending review if someone claimed but no review exists.</li>
<li>Rebuilds/updates every users status card in the list channel.</li>
</ul>

View File

@ -0,0 +1,47 @@
<h1>/usercards_rescan — Reconcile & refresh all cards</h1>
<p>One-shot maintenance pass that makes the servers user cards match reality.</p>
<h2>Access</h2>
<ul>
<li><strong>Moderator-only</strong> — requires the Discord permission <em>Manage Server</em>.</li>
<li>Must be used in a server channel (not DMs). The result is sent <em>ephemerally</em>.</li>
</ul>
<h2>What it fixes</h2>
<ol>
<li><strong>Rules / RoE agreement</strong>
<ul>
<liReads reactions from your configured Rules and RoE messages.</li>
<li>Adds/removes the corresponding roles so roles match the reactions.</li>
</ul>
</li>
<li><strong>Nickname claim &amp; reviews</strong>
<ul>
<li>If a member has an “accept” reaction on the Nickname message but has <em>no</em> pending/verified record and <em>no</em> open review, it opens a <em>pending nickname review</em> for them.</li>
<li>If a member <em>removed</em> their Nickname reaction, it clears any pending/verified state.</li>
</ul>
</li>
<li><strong>User cards</strong>
<ul>
<li>Updates (or recreates) the embed for each member in the configured “users list” channel.</li>
<li>Card color reflects: Rules, RoE, and Nickname status (✅ verified / ✔️ pending / ❌ not done).</li>
<li>Uses a stable footer marker (<code>UID:&lt;id&gt;</code>) to find/edit the right card; cleans up duplicates.</li>
</ul>
</li>
</ol>
<h2>Expected output</h2>
<p>The command replies (ephemeral) with counts like:</p>
<pre>Reconciled from messages. Changes — Rules: 3, RoE: 2, Nickname (added): 1, Nickname (removed): 0. Refreshed cards for 154 members.</pre>
<h2>Setup notes</h2>
<ul>
<li>Relies on your configured IDs (ENV/INI): Rules/RoE/Nickname message IDs and their role IDs, the Full Access role, the user-cards channel, and the mod/modlog channels.</li>
<li>Wont ping anyone; all posts/edits are sent with <em>no mentions</em>.</li>
</ul>
<h2>Tips</h2>
<ul>
<li>Run after importing a server, restoring from backup, or after downtime.</li>
<li>Large servers: this may take a moment while it walks members and edits cards. Its safe to run again.</li>
</ul>

View File

@ -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- Theres a **“Jump to message”** button on the mod card so they can see the original context.\n- When a mod picks ✅/❌, the card is updated to show **Approved/Rejected** (with who decided and when). The small ack message in your channel is also edited to reflect the result.\n\nIf approved, the player is added to the pirate list (threat level starts at 0 and grows with encounters).\n\n---\n\n## Rules & limits (so you dont run into errors)\n- **Account format:** must be `Name#12345`. Five digits. No spaces at the end.\n- **Proof link:** must be a Discord media link (see above). Non-Discord links are blocked.\n- **Duplicates:** already-approved players cant be re-reported; exact pending duplicates are blocked too.\n- **Rate limit:** one report per user every **60 seconds**.\n\nCommon messages you might see:\n- `Invalid account format` → fix to `Name#12345`.\n- `URL must be a Discord media link` → use cdn.discordapp.com or media.discordapp.net.\n- `A report for this player is already pending` → a mod is already reviewing one.\n- `This player is already in the pirate list` → no need to report again.\n\n---\n\n## Good proof examples\n- Screenshot of **names** and actions in the **event log**.\n- Short clip (1030s) that shows **what happened** and **who did it**.\n- If the situation is complex, add a quick text summary when you submit.\n\n---\n\n## Related actions\n- **/encounter** — log a new encounter with a known pirate. \n Fields:\n - *Pirate (name or account)* — `MuadDib` or `MuadDib#12345` (account preferred)\n - *Group size* — integer ≥ 1\n - *Kills* — integer ≥ 0 (0 = none/unknown)\n - *Destructive?* — `yes` / `no`\n - *Perceived Skill* — 05 (0 = unknown) \n Limits: one encounter per same pirate per reporter every **10 minutes**. These entries automatically update the pirates **threat level** and **encounter count**.\n\n- **/edit_pirate** *(moderators)* — update a pirates nickname/account.\n- **remove_pirate** *(moderators, hybrid)* — remove an approved entry.\n- **encounters_migrate_ids** *(moderators, hybrid)* — migrate old encounter identifiers.\n\n---\n\n## FAQ\n**Q: My proof link wont accept.** \nA: It must be a direct Discord CDN URL (ends in a known image/video extension). Open the media in browser from Discord and copy that address.\n\n**Q: I only know their nickname and it says “ambiguous”.** \nA: Use the **account** form `Name#12345`. Nicknames can be shared.\n\n**Q: Do I have to add proof?** \nA: Optional, but it **really** helps mods decide quickly. Screenshots are usually enough.\n",
"brief_html": "<h3 style=\"margin:0 0 4px 0;\">What it does</h3>\n<p style=\"margin:0 0 8px 0;\">\n Sends a player to the <strong>Pirate Review Queue</strong>. Mods get a card with ✅/❌.\n If you include a Discord media link, it will preview for them.\n</p>\n\n<h4 style=\"margin:10px 0 4px 0;\">Quick use — <code>/report</code></h4>\n<ul style=\"margin:0 0 8px 18px;\">\n <li><strong>In-game nickname</strong> e.g. <code>SandStalker</code></li>\n <li><strong>Account</strong> must be <code>Name#12345</code> (five digits)</li>\n <li><strong>Proof</strong> (optional, recommended) direct Discord CDN link\n (<code>cdn.discordapp.com</code> / <code>media.discordapp.net</code>)</li>\n</ul>\n\n<h4 style=\"margin:10px 0 4px 0;\">Limits</h4>\n<ul style=\"margin:0 0 6px 18px;\">\n <li>1 submission per user every 60s</li>\n <li>No duplicates (pending or already approved)</li>\n <li>Non-Discord media links are blocked</li>\n</ul>\n\n<p style=\"margin:6px 0 0 0; color:#9ca3af;\">\n Tip: Images show inside the card; videos stay as a link so the inline player works.\n</p>\n",
"details_html": "<h2 style=\"margin-top:0;\">Submit a pirate report</h2>\n<p>\n Use <code>/report</code> to send a player to the <strong>Pirate Review Queue</strong>.\n Mods see a compact card with your info and can approve or reject with one click.\n</p>\n\n<h3>Form fields</h3>\n<ul>\n <li><strong>In-game nickname</strong><br>\n The name they use in game. Example: <code>SandStalker</code></li>\n <li><strong>Account (Name#12345)</strong><br>\n Must end with <code>#</code> + <strong>five digits</strong>. Example: <code>SomeUser#12345</code><br>\n If you only know the nickname, try to grab the account too it prevents ambiguity.</li>\n <li><strong>Proof (Discord media URL)</strong> optional but highly encouraged<br>\n Use a direct Discord CDN link so it previews: <code>https://cdn.discordapp.com/…</code> or\n <code>https://media.discordapp.net/…</code><br>\n Allowed: <em>png, jpg, jpeg, gif, webp, mp4, webm, mov</em>.<br>\n How to get it: open the media in Discord → “Open in browser” → copy the address.</li>\n</ul>\n\n<h3>What happens next</h3>\n<ol>\n <li>You get a short “thanks” message in the channel.</li>\n <li>Mods receive an embed with ✅/❌. If you added an image, it shows inside the card.\n If its a video, the URL is kept above the card so the inline player works.</li>\n <li>A “Jump to message” button lets mods see your original context quickly.</li>\n <li>When a mod decides, the card updates to show <em>Approved/Rejected</em> with who and when.\n Your small ack message in the channel is edited to reflect the result.</li>\n <li>If approved, the player is added to the pirate list (threat level starts at 0).</li>\n</ol>\n\n<h3>Rules & common errors</h3>\n<ul>\n <li><strong>Account format</strong> must be <code>Name#12345</code>. Five digits no spaces at the end.</li>\n <li><strong>Proof link</strong> must be a Discord media URL. Non-Discord links are blocked.</li>\n <li><strong>Duplicates</strong> are blocked (already pending or already approved).</li>\n <li><strong>Rate limit</strong>: one report per user every 60 seconds.</li>\n</ul>\n<p style=\"color:#9ca3af\">\n Typical messages: “Invalid account format”, “URL must be a Discord media link”, “A report for this player is already pending”.\n</p>\n\n<h3>Related actions</h3>\n<ul>\n <li><code>/encounter</code> log a new encounter with a known pirate (updates threat level over time).</li>\n <li><code>/edit_pirate</code> (mods) update a pirates nickname/account.</li>\n <li><code>remove_pirate</code> (mods, hybrid) remove an entry.</li>\n</ul>\n"
},
"PirateReportCog.encounter": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/encounter</code></h2>\n\n<p>Log a pirate encounter so threat scores stay fresh. Takes ~10 seconds.</p>\n\n<ul>\n <li><b>Pirate</b> — account (<code>Name#12345</code>) or exact character name.<br>\n <small>Account is best. Character name must be unambiguous.</small></li>\n <li><b>Group size</b> — integer ≥ 1 (how many pirates in that group, including them).</li>\n <li><b>Kills</b> — integer ≥ 0 (0 if none/unknown).</li>\n <li><b>Destructive?</b> — yes/no (e.g., base or ornithopter destroyed).</li>\n <li><b>Perceived Skill</b> — 05 (0 = unknown).</li>\n</ul>\n\n<p><b>Rate limit:</b> you can report the <i>same</i> pirate once every <b>10 minutes</b>.</p>\n\n<div style=\"margin-top:.75rem\">\n <div class=\"usage\">/encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4</div>\n <div class=\"usage\" style=\"margin-top:.4rem\">/encounter SandStalker · group 5 · kills 0 · destructive no · skill 2</div>\n</div>\n\n<p style=\"margin-top:.75rem\"><small>After you submit, youll get a private “Encounter recorded” message and the pirate list updates shortly after.</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>\n <code>/encounter</code> adds a single encounter to the pirates history. These entries feed the\n threat score so the pirate list reflects how dangerous someone actually is right now.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Form fields</h2>\n<ul>\n <li><b>Pirate (name or account)</b><br>\n Best: <code>Name#12345</code>. If you only know the character name, it must match exactly and not be ambiguous.\n </li>\n <li><b>Group size</b><br>\n Integer <b>≥ 1</b>. How many pirates were in their group, including the reported pirate.\n </li>\n <li><b>Kills</b><br>\n Integer <b>≥ 0</b>. Use 0 for none/unknown.\n </li>\n <li><b>Destructive?</b><br>\n <code>yes</code>/<code>no</code>. “Yes” if a base/ornithopter was destroyed, etc.\n </li>\n <li><b>Perceived Skill</b><br>\n <b>05</b>. Use 0 if youre unsure. 5 = cracked aim/sweaty movement, 12 = casual.\n </li>\n</ul>\n\n<div class=\"usage\" style=\"margin:.6rem 0 0\">\n /encounter MuadDib#12345 · group 3 · kills 1 · destructive yes · skill 4\n</div>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Validation & messages</h2>\n<ul>\n <li>If the account format is wrong, youll be told what to fix (<code>Name#12345</code> ending with five digits).</li>\n <li>Character names must be an exact match. If multiple pirates share that name, youll be asked to use the account.</li>\n <li><b>Rate limit:</b> one report per user per pirate every <b>10 minutes</b>.</li>\n <li>Youll see a private <b>“Encounter recorded”</b> confirmation on success.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">How threat is calculated (short)</h2>\n<p>\n Each encounter contributes to a weighted score (0100):\n</p>\n<ul>\n <li><b>Kills</b>: higher if the pirate gets kills.</li>\n <li><b>Destructive</b>: higher if they destroy stuff.</li>\n <li><b>Group size</b>: higher if they roll with <code>group ≥ T</code> (T comes from config).</li>\n <li><b>Skill</b>: your 05 rating normalized and averaged across encounters.</li>\n</ul>\n<p>\n The weights are configurable (defaults: Kill 0.35, Destruction 0.30, Group 0.20, Skill 0.15). After you submit,\n the bot recomputes the pirates <b>threat level</b> and <b>encounter count</b>, then refreshes the list.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Tips</h2>\n<ul>\n <li>Use the account (<code>Name#12345</code>) whenever possible — it guarantees the report hits the right person.</li>\n <li>If you cant tell skill, leave it at 0. Thats fine.</li>\n <li>Group size includes the pirate youre reporting.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Common errors</h2>\n<ul>\n <li><b>“No such pirate registered.”</b> — Ask a mod to add them, or submit a <code>/report</code> first.</li>\n <li><b>“Character name is ambiguous.”</b> — Use the account tag (<code>Name#12345</code>).</li>\n <li><b>“You can only report the same pirate once every 10 minutes.”</b> — Wait a bit and try again.</li>\n</ul>\n"
},
"PirateReportCog.edit_pirate": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/edit_pirate</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n\n<p>Edit an existing pirate entry. Use this when a player changes nickname or account tag.</p>\n\n<ul>\n <li><b>Current account</b> — the exact existing account in the list (<code>Name#12345</code>).</li>\n <li><b>New in-game nickname</b> — optional.</li>\n <li><b>New account</b> — optional, must be <code>Name#12345</code>.</li>\n</ul>\n\n<div class=\"usage\" style=\"margin-top:.75rem\">/edit_pirate</div>\n\n<p style=\"margin-top:.6rem\"><small>\n You must provide <b>at least one</b> of: new nickname or new account.<br>\n The account format is strict: <code>#</code> + <b>five digits</b>. Duplicate accounts are blocked.\n</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>\n Updates a pirate record thats already approved. Typical cases: the player renamed their character,\n or moved to a new account tag.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Form fields</h2>\n<ul>\n <li><b>Current account</b> — must match exactly whats in the list (e.g., <code>SomeUser#12345</code>).</li>\n <li><b>New in-game nickname</b> — optional; leave empty to keep the current one.</li>\n <li><b>New account</b> — optional; must be <code>Name#12345</code>. If the new value already exists on another pirate, its rejected.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Rules & feedback</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>You must change at least one thing (nickname or account).</li>\n <li>On success youll see “✅ Pirate updated.” (ephemeral) and the public list refreshes.</li>\n <li>A short mod-log entry is posted for audit (“✏️ Edited pirate …”).</li>\n</ul>\n"
},
"PirateReportCog.encounters_migrate_ids": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/encounters_migrate_ids</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n\n<p>\n Converts old encounter records stored by <b>character name</b> to the\n canonical <b>account</b> (<code>Name#12345</code>).\n</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/encounters_migrate_ids</div>\n\n<p style=\"margin-top:.6rem\"><small>\n Useful after adding pirates where past encounters referenced only nicknames.\n</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>\n Scans stored encounters and rewrites any identifier that is a <b>character name</b> to the correct\n <b>account</b> for that pirate. Keeps data consistent for threat calculations.\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">How matching works</h2>\n<ul>\n <li>If the encounter already uses an account (<code>#</code> + five digits), its counted as “already accounts”.</li>\n <li>If the character name maps to exactly one pirate, its updated to that pirates account.</li>\n <li>If theres no pirate with that name, its counted as “not found”.</li>\n <li>If multiple pirates share the same nickname, its “ambiguous” and skipped.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Result</h2>\n<ul>\n <li>Replies with a compact summary (updated / already / ambiguous / not found).</li>\n <li>Refreshes the public pirate list afterwards.</li>\n</ul>\n\n<p style=\"margin-top:.6rem\"><small>\n <b>Moderator-only.</b>\n</small></p>\n"
},
"PirateReportCog.remove_pirate": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/remove_pirate</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n\n<p>Remove a pirate from the approved list by <b>account</b>.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/remove_pirate account_name:&nbsp;<code>Name#12345</code></div>\n\n<p style=\"margin-top:.6rem\"><small>\n Account format must be <code>Name#12345</code>. If the account isnt found, youll get “Pirate not found.”\n</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>Deletes the matching pirate record from the approved list.</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Parameters</h2>\n<ul>\n <li><b>account_name</b> — exact account tag (<code>Name#12345</code>).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Behavior & messages</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>If no match: “Pirate not found.”</li>\n <li>On success: “Removed.” (ephemeral via slash).</li>\n <li>Mod-log note is posted (“🗑️ Removed pirate …”) and the public list refreshes.</li>\n</ul>\n"
},
"AutoVCCog.avc_cleanup_now": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/avc_cleanup_now</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Immediately deletes empty Auto-VC rooms that have been idle past the configured delay, then renumbers the rest.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/avc_cleanup_now</div>\n\n<p style=\"margin-top:.6rem\"><small>\n Replies “Cleanup pass complete.” on success.\n</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Scans the target category for <b>managed</b> Auto-VC rooms.</li>\n <li>Deletes rooms that have been <b>empty</b> for at least the configured <b>delay</b> (seconds).</li>\n <li>Renumbers remaining rooms to a clean sequence (<code>Prefix 1..N</code>).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>After a busy period to tidy up stale empties.</li>\n <li>If numbering looks off and you want a quick clean + renumber in one go.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Bot needs: <b>Manage Channels</b>, <b>Connect</b>, and <b>Move Members</b> in the target category to function smoothly.</li>\n <li>Safe to run anytime. The bot pauses the background sweeper while this runs to avoid conflicts.</li>\n</ul>\n"
},
"AutoVCCog.avc_renumber": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/avc_renumber</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Force a rename of all managed rooms to a clean numeric order without deleting anything.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/avc_renumber</div>\n\n<p style=\"margin-top:.6rem\"><small>\n Replies “Renumbered.” on success.\n</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<p>Renames tracked rooms to <code>{prefix} 1</code>, <code>{prefix} 2</code>, … in creation order. No rooms are deleted.</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>Numbering drifted after manual edits or channel deletions.</li>\n <li>You changed the name prefix and want all rooms aligned quickly.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Bot needs <b>Manage Channels</b> to rename rooms.</li>\n <li>Safe to run anytime; the bot sequences this against its background sweeper.</li>\n</ul>\n"
},
"AutoVCCog.avc_status": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/avc_status</code></h2>\n<p>Shows current Auto-VC setup and the list of managed rooms.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/avc_status</div>\n\n<p style=\"margin-top:.6rem\"><small>\n Output includes trigger channel, target category, name prefix, cleanup delay, and each tracked room with its current state.\n</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it shows</h2>\n<ul>\n <li><b>Trigger</b> — the voice channel that spawns new rooms.</li>\n <li><b>Category</b> — where new rooms are created.</li>\n <li><b>Prefix</b> — base name (e.g. <code>Room</code> → <code>Room 1</code>, <code>Room 2</code>…).</li>\n <li><b>Delay</b> — seconds a room must stay empty before cleanup can delete it.</li>\n <li><b>Tracked rooms</b> — each rooms name and whether its empty or how many are inside. If a room is empty, an “idle Ns” timer may appear.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Example output</h2>\n<pre style=\"white-space:pre-wrap;margin:.25rem 0 0\">\nAuto-VC status:\nTrigger: &lt;#1234567890&gt; | Category: &lt;#2345678901&gt; | Prefix: `Room` | Delay: 30s\n- #1: Room 1 — 3 inside\n- #2: Room 2 — empty | idle 12s\n</pre>\n\n<p style=\"margin-top:.75rem\"><small>\n This command is read-only and safe to run anytime.\n</small></p>\n"
},
"NickNudgeCog.clear_nick_reviews": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/clear_nick_reviews</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Delete all <b>pending</b> nickname review records for this server.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/clear_nick_reviews</div>\n\n<p style=\"margin-top:.6rem\"><small>\nReplies with how many pending entries were removed (ephemeral).\n</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Removes every <b>pending</b> entry in the nickname review queue for this server.</li>\n <li>Does <u>not</u> touch approved/rejected history or verification flags.</li>\n <li>Useful when the queue got noisy or stuck after a busy period.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Result</h2>\n<p>\n Shows: <code>Cleared N pending nickname review(s).</code> (ephemeral)\n</p>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Writes a short entry to the mod log (if configured).</li>\n</ul>\n"
},
"NickNudgeCog.recreate_nick_review": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/recreate_nick_review</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Recreate a <b>single</b> users missing pending review.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/recreate_nick_review <b>user:</b> @Member</div>\n\n<p style=\"margin-top:.6rem\"><small>\nIf theyre already verified or already have a pending review, youll get a short explanation instead (ephemeral).\n</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Checks the selected member:</li>\n <ul>\n <li>If <b>already verified</b> → no action.</li>\n <li>If a <b>pending review exists</b> → no action.</li>\n <li>Else → marks them as claimed (if needed) and opens a new pending review in the mod channel.</li>\n </ul>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>One user fell through the cracks and needs a review opened again.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Logs to the mod log (if configured) with who triggered it.</li>\n <li>Reply is ephemeral.</li>\n</ul>\n"
},
"NickNudgeCog.recreate_nick_reviews": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/recreate_nick_reviews</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Scan the server and recreate any <b>missing</b> pending reviews for users who claimed but never got a review opened.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/recreate_nick_reviews</div>\n\n<p style=\"margin-top:.6rem\"><small>\nReplies: <code>Recreated X review(s); skipped Y.</code> (ephemeral)\n</small></p>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Looks at users who <b>claimed</b> nickname compliance but are <u>not verified</u> and have <u>no pending review</u>.</li>\n <li>Opens a fresh review in the mod channel for each such user (one per user, no duplicates).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>After a restart/outage where some pending reviews may have gone missing.</li>\n <li>Whenever you see “claimed” users without an open review.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions & notes</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Posts a summary to the mod log (if configured).</li>\n <li>Only processes this server; other servers are unaffected.</li>\n</ul>\n"
},
"PiratesListCog.pirates_list_refresh": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/pirates_list_refresh</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Rebuild the compact pirates list in the configured channel. Use this after adding/removing pirates or when encounter stats change.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/pirates_list_refresh</div>\n\n<ul style=\"margin:.6rem 0 0 1rem\">\n <li>Replies: <code>Pirates list refreshed.</code> (ephemeral)</li>\n <li>Runs per-guild cooldown: 10s</li>\n</ul>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Builds a compact, alphabetized list of verified pirates and posts it in the configured list channel.</li>\n <li>Entries are chunked to stay under Discords 2000-character limit; old chunks are edited or deleted as needed.</li>\n <li>No one gets pinged — all content is sanitized to avoid accidental mentions.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Entry format</h2>\n<pre style=\"margin:.5rem 0;white-space:pre-wrap\">\n- <b>Character</b> (<i>Account#12345</i>) [Threat%]\n - In group: <i>bucket</i>. Destructive: <i>bucket</i>. Encounters: N. Last: &lt;t:UNIX:R&gt;\n</pre>\n<ul>\n <li><b>Threat%</b> reflects combined encounter signals (kills, destructive behavior, group size, skill).</li>\n <li><b>In group / Destructive</b> use buckets: <code>unknown</code>, <code>never</code>, <code>rarely</code>, <code>sometimes</code>, <code>often</code>, <code>always</code>.</li>\n <li><b>Last</b> shows the most recent encounter as a relative timestamp.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">When to use</h2>\n<ul>\n <li>After a new pirate is approved or removed.</li>\n <li>After logging encounters that meaningfully change threat or buckets.</li>\n <li>When the list looks stale or out of order.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Behavior & notes</h2>\n<ul>\n <li>Sorts by character name, then account.</li>\n <li>If there are no pirates yet, shows a single placeholder line.</li>\n <li>If the list channel isnt configured or cant be found, nothing is posted (check your config).</li>\n <li>Per-guild cooldown: 10 seconds (prevents spam).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Config that affects output</h2>\n<ul>\n <li><code>pirates_list_channel_id</code> — where the list is posted.</li>\n <li><code>threat_group_threshold</code> — minimum party size that counts as “in group”.</li>\n <li><code>threat_min_samples_for_stats</code> — encounters needed before buckets stop showing <code>unknown</code>.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions</h2>\n<ul>\n <li><b>Moderator-only.</b></li>\n <li>Bot needs permission to read and write in the list channel.</li>\n</ul>\n"
},
"PowerActionsCog.power_restart": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/power restart</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Safely restarts the bot after logging a clear reason to the modlog.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/power restart reason:\"Restarting to enable new permissions sync after config change\"</div>\n\n<ul style=\"margin:.6rem 0 0 1rem\">\n <li>Requires a descriptive reason (see details).</li>\n <li>Replies: <code>🔁 Restart requested — logging to modlog and restarting…</code> (ephemeral)</li>\n <li>Bot restarts a couple seconds after the reply.</li>\n</ul>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Checks youre a moderator, validates your reason, and posts a restart entry to the modlog (who, when, version, reason).</li>\n <li>Closes the bot cleanly and exits; your process manager/container brings it back up.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Reason requirements</h2>\n<p>The <code>reason</code> must be specific enough to audit later:</p>\n<ul>\n <li>At least <b>20 characters</b></li>\n <li>At least <b>4 words</b></li>\n <li>At least <b>3 words</b> with length ≥ 3</li>\n <li>Rejects vague phrases like: <code>stuck</code>, <code>idk</code>, <code>don't know</code>, <code>unknown</code>, <code>?</code>,\n <code>lag</code>, <code>restart</code>, <code>restarting</code>, <code>update</code>, <code>updating</code>,\n <code>bug</code>, <code>crash</code>, <code>crashed</code></li>\n</ul>\n\n<h3 style=\"margin:.75rem 0 .4rem\">Examples</h3>\n<div style=\"display:grid; gap:.5rem\">\n <div>\n <div style=\"font-weight:600\">✅ Good</div>\n <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"Reloading cogs after changing threat weights and enabling nick loop; avoids inconsistent state.\"</pre>\n </div>\n <div>\n <div style=\"font-weight:600\">❌ Bad</div>\n <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"update\"</pre>\n </div>\n</div>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">What youll see</h2>\n<ul>\n <li>Ephemeral confirmation in chat.</li>\n <li>Modlog post similar to:\n <pre style=\"margin:.4rem 0;white-space:pre-wrap\">🔁 Bot Restart Requested\nBy: @YourName\nWhen: 2025-01-01 12:34 UTC\nRunning version: vX.Y.Z\nReason: Reloading cogs after threat weight change…</pre>\n </li>\n <li>Bot goes offline briefly, then comes back once the host restarts it.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Config that affects it</h2>\n<ul>\n <li><code>modlog_channel_id</code> — where the restart entry is posted. If not set, it falls back to server logs only.</li>\n <li><code>home_guild_id</code> — where the <code>/power</code> group is registered (home-guild only vs global).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions</h2>\n<ul>\n <li><b>Moderator-only.</b> Non-mods are blocked with an ephemeral message.</li>\n <li>Bot just needs permission to send messages in the modlog channel.</li>\n</ul>\n"
},
"PowerActionsCog.restart": {
"brief_html": "<h2 style=\"margin:0 0 .5rem 0\">Quick usage — <code>/power restart</code> <small style=\"opacity:.8\">[Moderator]</small></h2>\n<p>Safely restarts the bot after logging a clear reason to the modlog.</p>\n\n<div class=\"usage\" style=\"margin-top:.5rem\">/power restart reason:\"Restarting to enable new permissions sync after config change\"</div>\n\n<ul style=\"margin:.6rem 0 0 1rem\">\n <li>Requires a descriptive reason (see details).</li>\n <li>Replies: <code>🔁 Restart requested — logging to modlog and restarting…</code> (ephemeral)</li>\n <li>Bot restarts a couple seconds after the reply.</li>\n</ul>\n",
"details_html": "<h2 style=\"margin:0 0 .5rem 0\">What it does</h2>\n<ul>\n <li>Checks youre a moderator, validates your reason, and posts a restart entry to the modlog (who, when, version, reason).</li>\n <li>Closes the bot cleanly and exits; your process manager/container brings it back up.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Reason requirements</h2>\n<p>The <code>reason</code> must be specific enough to audit later:</p>\n<ul>\n <li>At least <b>20 characters</b></li>\n <li>At least <b>4 words</b></li>\n <li>At least <b>3 words</b> with length ≥ 3</li>\n <li>Rejects vague phrases like: <code>stuck</code>, <code>idk</code>, <code>don't know</code>, <code>unknown</code>, <code>?</code>,\n <code>lag</code>, <code>restart</code>, <code>restarting</code>, <code>update</code>, <code>updating</code>,\n <code>bug</code>, <code>crash</code>, <code>crashed</code></li>\n</ul>\n\n<h3 style=\"margin:.75rem 0 .4rem\">Examples</h3>\n<div style=\"display:grid; gap:.5rem\">\n <div>\n <div style=\"font-weight:600\">✅ Good</div>\n <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"Reloading cogs after changing threat weights and enabling nick loop; avoids inconsistent state.\"</pre>\n </div>\n <div>\n <div style=\"font-weight:600\">❌ Bad</div>\n <pre style=\"margin:.25rem 0;white-space:pre-wrap\">/power restart reason:\"update\"</pre>\n </div>\n</div>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">What youll see</h2>\n<ul>\n <li>Ephemeral confirmation in chat.</li>\n <li>Modlog post similar to:\n <pre style=\"margin:.4rem 0;white-space:pre-wrap\">🔁 Bot Restart Requested\nBy: @YourName\nWhen: 2025-01-01 12:34 UTC\nRunning version: vX.Y.Z\nReason: Reloading cogs after threat weight change…</pre>\n </li>\n <li>Bot goes offline briefly, then comes back once the host restarts it.</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Config that affects it</h2>\n<ul>\n <li><code>modlog_channel_id</code> — where the restart entry is posted. If not set, it falls back to server logs only.</li>\n <li><code>home_guild_id</code> — where the <code>/power</code> group is registered (home-guild only vs global).</li>\n</ul>\n\n<hr>\n\n<h2 style=\"margin:.75rem 0 .5rem\">Permissions</h2>\n<ul>\n <li><b>Moderator-only.</b> Non-mods are blocked with an ephemeral message.</li>\n <li>Bot just needs permission to send messages in the modlog channel.</li>\n</ul>\n"
},
"ReactionRoleCog.nick_same": {
"brief_html": "<h2>/nick_same</h2>\n<p><strong>Nick matches my in-game name.</strong> Tells mods your current <em>server nickname</em> (or global display name if you havent set one) already matches your in-game name, and opens a quick review.</p>\n\n<h3>Usage</h3>\n<pre>/nick_same</pre>\n\n<ul>\n <li>Youll get an <em>ephemeral</em> “thanks” message.</li>\n <li>Moderators get a review with ✅ / ❌ in the mod channel.</li>\n <li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + this nickname claim (pending or verified).</li>\n</ul>\n\n<h3>Tips</h3>\n<ul>\n <li>Run it in the server (not in DMs).</li>\n <li>If you recently changed your nick, this will trigger a re-review.</li>\n <li>Alternative: react ✅ on the “Nickname” rules message to claim the same thing.</li>\n</ul>\n",
"details_html": "<h1>/nick_same — Nickname claim</h1>\n<p>Use this when your <strong>server nickname</strong> already matches your in-game character name. It opens a moderator review so you can get (or keep) access without typing anything else.</p>\n\n<h2>What it does</h2>\n<ul>\n <li>Marks your nickname as <em>claimed</em> and opens exactly <strong>one</strong> pending review for moderators.</li>\n <li>Youll see a short <em>ephemeral</em> confirmation in the channel.</li>\n <li>Moderators get a message in the mod channel with ✅ Approve / ❌ Reject.</li>\n <li><strong>Full Access</strong> is granted automatically when you have: Rules ✅ + Engagement ✅ + Nickname <em>claimed</em> (pending or verified).</li>\n</ul>\n\n<h2>How to use</h2>\n<pre>/nick_same</pre>\n<ul>\n <li>Run it in the server, any channel where you can use slash commands.</li>\n <li>No extra arguments. If your display name already matches the game name, thats enough.</li>\n</ul>\n\n<h2>Typical flow</h2>\n<ol>\n <li><strong>You</strong> run <code>/nick_same</code>. The bot replies “Thanks — your nickname claim was sent for moderator review.”</li>\n <li><strong>Mods</strong> see a review card with your previous nick (if known) and the current one.</li>\n <li>They press ✅ to verify or ❌ to reject. You keep (or lose) Full Access depending on Rules/Engagement and the result.</li>\n</ol>\n\n<h2>Good to know</h2>\n<ul>\n <li>If a verified user changes their nickname later, verification is <strong>automatically revoked</strong>. Just run <code>/nick_same</code> again (or react ✅ on the nickname message) to re-claim and re-review.</li>\n <li>The command is idempotent: if a review is already pending, it wont open duplicates.</li>\n <li>Alternative path: reacting ✅ on the <em>Nickname</em> rules message triggers the same review and access checks.</li>\n</ul>\n\n<h2>Troubleshooting</h2>\n<ul>\n <li><strong>No response?</strong> Make sure you used it inside the server (not DMs) and that slash commands are allowed in the channel.</li>\n <li><strong>Still no Full Access?</strong> You need all three: Rules ✅, Engagement ✅, and a nickname claim (pending or verified). Missing any one will remove the role.</li>\n <li><strong>Mods didnt get a review?</strong> Ping a moderator; the mod channel might be misconfigured.</li>\n</ul>\n"
},
"SpicePayCog.spicepay": {
"brief_html": "<h2>/spicepay</h2>\n<p><strong>Open the Spice Pay wizard.</strong> Step through participants, roles, payout type (Sand or Melange), and weighting. Shows a live preview and lets you post the result.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay [participants] [force_new]</pre>\n<ul>\n <li><code>participants</code> (optional): total people, incl. owners (125).</li>\n <li><code>force_new</code> (optional): start fresh if you already have a session.</li>\n</ul>\n\n<h3>Quick flow</h3>\n<ol>\n <li>Enter the total yield and participant count.</li>\n <li>Add/edit each participant (name, active %, owner roles).</li>\n <li>Toggle payout: Sand ⟷ Melange (set refinery yield for Melange).</li>\n <li>Adjust weighting factors or use a preset.</li>\n <li>Finish → preview → Post to channel.</li>\n</ol>\n\n<h3>Notes</h3>\n<ul>\n <li>0% active = owner-only (gets owner perks but didnt actively join).</li>\n <li>Melange: exactly one <em>Refiner</em> owner is required.</li>\n <li>Rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>\n</ul>",
"details_html": "<h1>/spicepay — Guided payout</h1>\n<p>Calculate a fair split for a spice run. The wizard keeps things simple and transparent for the team.</p>\n\n<h2>Start</h2>\n<pre>/spicepay [participants] [force_new]</pre>\n<ul>\n <li>If <code>participants</code> is omitted, you set it in the first modal (125).</li>\n <li>Use <code>force_new: true</code> to discard an existing session and start clean.</li>\n</ul>\n\n<h2>Setup modal</h2>\n<ul>\n <li><strong>Total spice yield (sand)</strong> — integer ≥ 0.</li>\n <li><strong>Participants</strong> — include <em>owners</em> (Refiner/Carrier/Crawler). People only; vehicles/refinery are <em>owned</em> by people.</li>\n</ul>\n\n<h2>Editing participants</h2>\n<p>For each slot:</p>\n<ul>\n <li><strong>Name</strong> — free text (mention text is fine).</li>\n <li><strong>Active %</strong> — 0100. <em>0% = owner-only</em> (didnt actively join).</li>\n <li><strong>Owner of</strong> (optional) — any of: <code>refiner</code>, <code>carrier</code>, <code>crawler</code>. Flexible input (e.g. “lsr”, “refinery”, “car”, “cr”).</li>\n</ul>\n<p>Use <em>Add / Edit participant</em>, <em>Previous/Next</em> to navigate. The preview shows filled vs. empty slots and highlights “owner-only”.</p>\n\n<h2>Payout type</h2>\n<ul>\n <li><strong>Sand</strong> — no refinery cut; 0 or 1 Refiner owner allowed.</li>\n <li><strong>Melange</strong> — <em>requires exactly one</em> Refiner owner. First a refinery cut % is taken and paid to the Refiner, then the rest is split.</li>\n</ul>\n<p>When switching to <strong>Melange</strong>, set the refinery yield (integer) in the modal.</p>\n\n<h2>Weighting (the math, simplified)</h2>\n<ul>\n <li>Each person gets a <em>weight</em>: <strong>Base × Active%</strong> + bonuses for owning <strong>Carrier</strong>/<strong>Crawler</strong>.</li>\n <li>We split the pot <em>proportionally</em> by weights (theyre normalized; only ratios matter).</li>\n <li>Melange only: take <strong>Refinery cut %</strong> first → Refiner. Any rounding leftovers go to Refiner (Melange) or top earner (Sand).</li>\n</ul>\n\n<h3>Controls</h3>\n<ul>\n <li><em>Toggle payout: Sand/Melange</em> — switches mode (prompts for refinery yield on Melange).</li>\n <li><em>Adjust weighting factors</em> — change:\n <ul>\n <li><strong>Base × active %</strong> (default <em>server setting</em>)</li>\n <li><strong>Carrier bonus</strong> ( + )</li>\n <li><strong>Crawler bonus</strong> ( + )</li>\n <li><strong>Refinery cut %</strong> (Melange only)</li>\n </ul>\n </li>\n <li><em>Weights help</em> — short explanation inline.</li>\n <li><em>Presets</em> — Owner-heavy / Participation-heavy / Fair (server defaults) / Even split.</li>\n <li><em>Save as my defaults</em> — stores your weights for future runs (per user).</li>\n <li><em>Finish</em> — shows a neat preview (table) and lets you <em>Post to channel</em>.</li>\n</ul>\n\n<h2>Validation & limits</h2>\n<ul>\n <li>Participants: 125.</li>\n <li>Active %: 0100.</li>\n <li>Melange: exactly one Refiner owner required; Sand: 0 or 1 allowed.</li>\n <li>No duplicate names (duplicates are auto-disambiguated in preview).</li>\n</ul>\n\n<h2>Posting</h2>\n<p>The post includes:</p>\n<ul>\n <li>Header (Sand or Melange) with the chosen emoji.</li>\n <li>Weighting summary (Base, bonuses, and refinery cut if Melange).</li>\n <li>A clean monospace table: Name · Active% · Owner of/Role · Amount.</li>\n</ul>\n<p><em>Footnote:</em> “0% = owner only”.</p>\n"
},
"SpicePayCog.spicepay_cancel": {
"brief_html": "<h2>/spicepay_cancel</h2>\n<p><strong>Cancel your active Spice Pay session.</strong> Clears everything for you so you can start fresh.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay_cancel</pre>\n\n<ul>\n <li>Removes your in-memory session (does not post anything).</li>\n <li>Run <code>/spicepay</code> again to start over.</li>\n</ul>\n",
"details_html": "<h1>/spicepay_cancel — Cancel session</h1>\n<p>Discards your current wizard state. Handy if you mis-entered totals or want to restructure the roster.</p>\n\n<h2>What it does</h2>\n<ul>\n <li>Deletes your session (you + this server).</li>\n <li>Nothing is posted publicly.</li>\n</ul>\n\n<h2>Next steps</h2>\n<p>Run <code>/spicepay</code> to start again. You can optionally pass <code>participants</code> or just set them in the first modal.</p>\n"
},
"SpicePayCog.spicepay_config": {
"brief_html": "<h2>/spicepay_config</h2>\n<p><strong>Show server SpicePay weights.</strong> Read-only view of the defaults the wizard uses.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay_config</pre>\n\n<ul>\n <li>Displays: Refinery cut %, Base × active %, Carrier bonus, Crawler bonus.</li>\n <li>Change via ENV/INI; restart the bot to apply.</li>\n</ul>\n",
"details_html": "<h1>/spicepay_config — Server weights</h1>\n<p>Shows the current default weighting used by the Spice Pay wizard.</p>\n\n<h2>Fields</h2>\n<ul>\n <li><strong>Refinery cut %</strong> (Melange only) — percent taken first and paid to the Refiner owner.</li>\n <li><strong>Base × active %</strong> — the core share, scaled by each players active participation.</li>\n <li><strong>Carrier bonus</strong> — flat weight added if the person owns the carrier.</li>\n <li><strong>Crawler bonus</strong> — flat weight added if the person owns the crawler.</li>\n</ul>\n\n<h2>Where to change</h2>\n<p>Configure via environment variables or your INI, then restart the bot. The wizards <em>Adjust weighting factors</em> lets users override per-run; those dont change the server defaults.</p>\n\n<h2>Tips</h2>\n<ul>\n <li>Use <em>Presets</em> in the wizard to quickly switch philosophy (owner-heavy, participation-heavy, etc.).</li>\n <li>Users can <em>Save as my defaults</em> to keep their personal weights for future runs.</li>\n</ul>\n"
},
"SpicePayCog.spicepay_resume": {
"brief_html": "<h2>/spicepay_resume</h2>\n<p><strong>Reopen your active Spice Pay session.</strong> Useful if you closed the wizard message by accident.</p>\n\n<h3>Usage</h3>\n<pre>/spicepay_resume</pre>\n\n<ul>\n <li>If a session exists, the wizard view is shown again.</li>\n <li>If not, youll be told to run <code>/spicepay</code>.</li>\n</ul>\n",
"details_html": "<h1>/spicepay_resume — Resume wizard</h1>\n<p>Brings back the current session UI (if you already started one with <code>/spicepay</code>).</p>\n\n<h2>When to use</h2>\n<ul>\n <li>You dismissed the ephemeral wizard message and want it back.</li>\n <li>Youre mid-entry and dont want to start over.</li>\n</ul>\n\n<h2>Behavior</h2>\n<ul>\n <li>If a session exists (keyed to you + this server), it re-renders the progress view.</li>\n <li>If none exists, you get a friendly nudge to start a new one.</li>\n</ul>\n"
},
"help": {
"brief_html": "<h2>!help</h2>\n<p>Show a quick overview of commands or details about a specific command.</p>\n\n<h3>Usage</h3>\n<pre>!help\n!help spicepay\n!help pirate\n!help encounter</pre>\n\n<ul>\n <li>Add a word to narrow down the list (e.g., <code>!help spice</code>).</li>\n</ul>\n",
"details_html": "<h1>!help — Command help &amp; search</h1>\n<p>Use <code>!help</code> to see what the bot can do, or pass a keyword/command to jump straight to specifics.</p>\n\n<h2>What it does</h2>\n<ul>\n <li>Lists available commands you can run here.</li>\n <li>Filters by a keyword (partial matches work).</li>\n <li>When supported, shows usage and a short explanation for a specific command.</li>\n</ul>\n\n<h2>How to use it</h2>\n<ol>\n <li><strong>Overview:</strong> <code>!help</code> — shows a concise command list.</li>\n <li><strong>Filter:</strong> <code>!help spice</code> — narrows to commands that match “spice”.</li>\n <li><strong>Exact command:</strong> <code>!help encounter</code> — shows usage for a single command if available.</li>\n</ol>\n\n<h2>Examples</h2>\n<pre>!help\n!help pirate\n!help spicepay\n!help encounter</pre>\n\n<h2>Notes &amp; tips</h2>\n<ul>\n <li>If you dont get any results, try a simpler keyword (e.g., <em>“spice”</em> instead of <em>“spicepay resume”</em>).</li>\n</ul>\n\n<h2>Troubleshooting</h2>\n<ul>\n <li><em>“No such command”</em> — Check your spelling or try a broader keyword.</li>\n</ul>\n"
},
"PirateCardsCog.pirate_cards_rebuild": {
"brief_html": "<h2>/pirate_cards_rebuild</h2>\n<p><strong>Rebuild or update pirate cards</strong> for every known pirate in the configured channel.</p>\n\n<h3>Usage</h3>\n<pre>/pirate_cards_rebuild</pre>\n\n<ul>\n <li><strong>Moderator-only</strong>.</li>\n <li>Run in a server channel. The reply is ephemeral.</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n <li>Creates or updates one embed “card” per pirate in the pirates list channel.</li>\n <li>Each card shows Account, Threat %, grouping/destructive behavior buckets, encounters count, and last seen date.</li>\n <li>Card color reflects threat (unknown → gray; low → green; high → red).</li>\n</ul>\n",
"details_html": "<h1>/pirate_cards_rebuild — Rebuild all pirate cards</h1>\n<p>Runs a full pass that makes sure each verified pirate has a fresh, accurate card in the configured channel.</p>\n\n<h2>Access</h2>\n<ul>\n <li><strong>Moderator-only</strong>.</li>\n <li>Must be used in a server channel (not DMs). The confirmation is sent <em>ephemerally</em>.</li>\n</ul>\n\n<h2>What it updates</h2>\n<ul>\n <li><strong>Card fields</strong>: Account name, Threat %, “In groups” bucket, “Destructive” bucket, total encounters, and last encounter date (UTC).</li>\n <li><strong>Colors</strong>: Unknown samples → dark gray. Otherwise a green→red gradient based on Threat %.</li>\n <li><strong>Safety</strong>: User-provided names are escaped and mentions are neutralized (no pings).</li>\n</ul>\n\n<h2>Behavior</h2>\n<ol>\n <li>For each pirate in the internal list:\n <ul>\n <li>If their card already exists, it edits the embed in place.</li>\n <li>If it cant find the message, it posts a new card and records the message ID.</li>\n </ul>\n </li>\n <li>Works serially per guild to avoid race conditions.</li>\n</ol>\n\n<h2>Output</h2>\n<p>Ephemeral summary, e.g.:</p>\n<pre>Rebuilt/updated 27 pirate cards.</pre>\n\n<h2>Setup notes</h2>\n<ul>\n <li>Uses <code>pirates_list_channel_id</code> for the destination channel.</li>\n <li>Threat %, group/destructive buckets, and last seen are computed from stored encounters.</li>\n <li>Bucket thresholds respect your server settings (e.g., <code>threat_group_threshold</code>, <code>threat_min_samples_for_stats</code>).</li>\n</ul>\n\n<h2>When to run it</h2>\n<ul>\n <li>After importing or migrating data.</li>\n <li>After a batch of new pirates is approved.</li>\n <li>Any time the cards look out of date.</li>\n</ul>\n"
},
"UserCardsCog.usercards_rescan": {
"brief_html": "<h2>/usercards_rescan</h2>\n<p><strong>Re-check everyone and refresh the user cards.</strong> Also repairs Roles/RoE/nickname claims from the live reaction messages, and re-opens any missing nickname reviews.</p>\n\n<h3>Usage</h3>\n<pre>/usercards_rescan</pre>\n\n<ul>\n <li><strong>Moderator-only</strong> (requires <em>Manage Server</em>).</li>\n <li>Runs in the server; reply is ephemeral with a short summary.</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n <li>Reconciles from the configured Rules / RoE / Nickname reaction messages.</li>\n <li>Grants/removes the Rules &amp; RoE roles to match reactions.</li>\n <li>For Nickname: opens a pending review if someone claimed but no review exists.</li>\n <li>Rebuilds/updates every users status card in the list channel.</li>\n</ul>\n",
"details_html": "<h1>/usercards_rescan — Reconcile & refresh all cards</h1>\n<p>One-shot maintenance pass that makes the servers user cards match reality.</p>\n\n<h2>Access</h2>\n<ul>\n <li><strong>Moderator-only</strong> — requires the Discord permission <em>Manage Server</em>.</li>\n <li>Must be used in a server channel (not DMs). The result is sent <em>ephemerally</em>.</li>\n</ul>\n\n<h2>What it fixes</h2>\n<ol>\n <li><strong>Rules / RoE agreement</strong>\n <ul>\n <liReads reactions from your configured Rules and RoE messages.</li>\n <li>Adds/removes the corresponding roles so roles match the reactions.</li>\n </ul>\n </li>\n <li><strong>Nickname claim &amp; reviews</strong>\n <ul>\n <li>If a member has an “accept” reaction on the Nickname message but has <em>no</em> pending/verified record and <em>no</em> open review, it opens a <em>pending nickname review</em> for them.</li>\n <li>If a member <em>removed</em> their Nickname reaction, it clears any pending/verified state.</li>\n </ul>\n </li>\n <li><strong>User cards</strong>\n <ul>\n <li>Updates (or recreates) the embed for each member in the configured “users list” channel.</li>\n <li>Card color reflects: Rules, RoE, and Nickname status (✅ verified / ✔️ pending / ❌ not done).</li>\n <li>Uses a stable footer marker (<code>UID:&lt;id&gt;</code>) to find/edit the right card; cleans up duplicates.</li>\n </ul>\n </li>\n</ol>\n\n<h2>Expected output</h2>\n<p>The command replies (ephemeral) with counts like:</p>\n<pre>Reconciled from messages. Changes — Rules: 3, RoE: 2, Nickname (added): 1, Nickname (removed): 0. Refreshed cards for 154 members.</pre>\n\n<h2>Setup notes</h2>\n<ul>\n <li>Relies on your configured IDs (ENV/INI): Rules/RoE/Nickname message IDs and their role IDs, the Full Access role, the user-cards channel, and the mod/modlog channels.</li>\n <li>Wont ping anyone; all posts/edits are sent with <em>no mentions</em>.</li>\n</ul>\n\n<h2>Tips</h2>\n<ul>\n <li>Run after importing a server, restoring from backup, or after downtime.</li>\n <li>Large servers: this may take a moment while it walks members and edits cards. Its safe to run again.</li>\n</ul>\n"
},
"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 &lt;action&gt; [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 &lt;action&gt; [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 its 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 its 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 servers 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"
}
}

View File

@ -0,0 +1,12 @@
<h2>!help</h2>
<p>Show a quick overview of commands or details about a specific command.</p>
<h3>Usage</h3>
<pre>!help
!help spicepay
!help pirate
!help encounter</pre>
<ul>
<li>Add a word to narrow down the list (e.g., <code>!help spice</code>).</li>
</ul>

View File

@ -0,0 +1,32 @@
<h1>!help — Command help &amp; search</h1>
<p>Use <code>!help</code> to see what the bot can do, or pass a keyword/command to jump straight to specifics.</p>
<h2>What it does</h2>
<ul>
<li>Lists available commands you can run here.</li>
<li>Filters by a keyword (partial matches work).</li>
<li>When supported, shows usage and a short explanation for a specific command.</li>
</ul>
<h2>How to use it</h2>
<ol>
<li><strong>Overview:</strong> <code>!help</code> — shows a concise command list.</li>
<li><strong>Filter:</strong> <code>!help spice</code> — narrows to commands that match “spice”.</li>
<li><strong>Exact command:</strong> <code>!help encounter</code> — shows usage for a single command if available.</li>
</ol>
<h2>Examples</h2>
<pre>!help
!help pirate
!help spicepay
!help encounter</pre>
<h2>Notes &amp; tips</h2>
<ul>
<li>If you dont get any results, try a simpler keyword (e.g., <em>“spice”</em> instead of <em>“spicepay resume”</em>).</li>
</ul>
<h2>Troubleshooting</h2>
<ul>
<li><em>“No such command”</em> — Check your spelling or try a broader keyword.</li>
</ul>

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/docs/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

7
assets/docs/no.svg Normal file
View 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

View File

@ -0,0 +1,12 @@
{
"name": "ShaiWatcher",
"short_name": "ShaiWatcher",
"icons": [
{ "src": "/assets/docs/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/assets/docs/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"theme_color": "#0f172a",
"background_color": "#0b0f14",
"display": "standalone",
"start_url": "/"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

219
bot.py Normal file
View File

@ -0,0 +1,219 @@
import os, signal, asyncio, pathlib
import discord
from discord.ext import commands
from dotenv import load_dotenv
from data_manager import DataManager
from modules.common.settings import cfg as cfg_helper
from modules.common.boot_notice import post_boot_notice
# Version consists of:
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.5.1.2.a4"
# ---------- Env loading ----------
load_dotenv()
def _get_env(name: str, default: str = "") -> str:
v = os.getenv(name, "")
return (v or "").strip().strip('"').strip("'") or default
TOKEN = _get_env("DISCORD_TOKEN")
DATA_FILE = _get_env("DATA_FILE") or "./data/data.json"
print("[Config] DISCORD_TOKEN set:", bool(TOKEN))
print("[Config] DATA_FILE:", DATA_FILE)
# ---------- Ensure data path exists (fallback if not writable) ----------
data_dir = os.path.dirname(DATA_FILE) or "."
try:
os.makedirs(data_dir, exist_ok=True)
except PermissionError:
fallback = "./data/data.json"
print(f"[Config] No permission to create '{data_dir}'. Falling back to {fallback}")
DATA_FILE = fallback
data_dir = os.path.dirname(DATA_FILE)
os.makedirs(data_dir, exist_ok=True)
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, "w", encoding="utf-8") as f:
f.write("{}")
# ---------- Discord intents ----------
intents = discord.Intents.default()
intents.guilds = True
intents.members = True
intents.message_content = True
intents.reactions = True
intents.emojis_and_stickers = True
intents.voice_states = True
# ---------- Bot + DataManager ----------
if not TOKEN:
print("[Config] WARNING: DISCORD_TOKEN is empty. The bot will fail to log in.")
bot = commands.Bot(command_prefix="!", intents=intents)
# Ensure data path exists and is seeded
os.makedirs(os.path.dirname(DATA_FILE) or ".", exist_ok=True)
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, "w", encoding="utf-8") as f:
f.write("{}")
bot.data_manager = DataManager(DATA_FILE)
# ---------- Self-check: resolve from ENV first, then cfg_helper ----------
def _resolve_channel_id(c, key: str) -> int:
"""
Resolve channel IDs from the runtime settings store (cfg), with a final
fallback to legacy bot.config['DEFAULT'] if present. No SHAI_* env usage.
"""
try:
v = int(c.int(key, 0))
if v:
return v
except Exception:
pass
try:
# legacy DEFAULT mapping (ConfigParser-like or our shim)
v = int(getattr(c, "get", lambda *_: 0)(key, 0))
if v:
return v
except Exception:
pass
return 0
async def _guild_selfcheck(g: discord.Guild, c):
problems = []
def _need_channel(id_key, *perms):
cid = _resolve_channel_id(c, id_key)
if not cid:
problems.append(f"Missing config key: {id_key}")
return
ch = g.get_channel(cid)
if not ch:
problems.append(f"Channel not found: {id_key}={cid}")
return
me = g.me
p = ch.permissions_for(me)
for perm in perms:
if not getattr(p, perm, False):
problems.append(f"Missing permission on #{ch.name}: {perm}")
_need_channel('mod_channel_id', 'read_messages', 'send_messages', 'add_reactions', 'read_message_history')
_need_channel('modlog_channel_id', 'read_messages', 'send_messages')
_need_channel('pirates_list_channel_id', 'read_messages', 'send_messages')
_need_channel('dd_channel_id', 'read_messages', 'send_messages', 'read_message_history')
if problems:
print(f"[SelfCheck:{g.name}]")
for p in problems:
print(" -", p)
# ---------- events ----------
@bot.event
async def on_ready():
import asyncio
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
print("[Intents] members:", bot.intents.members,
"/ message_content:", bot.intents.message_content,
"/ voice_states:", bot.intents.voice_states)
env_cfg = cfg_helper(bot)
# Per-guild permission sanity checks (env-aware)
try:
await asyncio.gather(*[_guild_selfcheck(g, env_cfg) for g in bot.guilds])
except Exception as e:
print("[SelfCheck] failed:", repr(e))
# ---------- Slash command scope & sync ----------
try:
# env_cfg already exists above in on_ready()
gid = env_cfg.int("home_guild_id", 0)
if gid > 0:
print(f"[Slash] Mode: GUILD-ONLY → {gid}")
guild_obj = discord.Object(id=gid)
# Copy all currently-loaded global commands to HOME guild
bot.tree.copy_global_to(guild=guild_obj)
g_cmds = await bot.tree.sync(guild=guild_obj)
g_names = ", ".join(f"/{c.name}" for c in g_cmds) if g_cmds else "(none)"
print(f"[Slash] Synced {len(g_cmds)} commands to guild {gid}: {g_names}")
# Clear global so only guild-scoped remain
bot.tree.clear_commands(guild=None)
cleared = await bot.tree.sync() # push empty global set
print(f"[Slash] Cleared global commands (now {len(cleared)}).")
# Debug: list actual state after sync
try:
global_cmds = await bot.tree.fetch_commands()
print(f"[Slash] Global commands ({len(global_cmds)}): {', '.join(f'/{c.name}' for c in global_cmds) or '(none)'}")
except Exception as e:
print("[Slash] Failed to fetch global commands:", repr(e))
try:
g_cmds = await bot.tree.fetch_commands(guild=guild_obj)
print(f"[Slash] Guild {gid} commands ({len(g_cmds)}): {', '.join(f'/{c.name}' for c in g_cmds) or '(none)'}")
except Exception as e:
print(f"[Slash] Failed to fetch commands for guild {gid}:", repr(e))
else:
print("[Slash] Mode: GLOBAL (HOME_GUILD_ID not set)")
global_cmds = await bot.tree.sync()
names = ", ".join(f"/{c.name}" for c in global_cmds) if global_cmds else "(none)"
print(f"[Slash] Synced {len(global_cmds)} commands globally: {names}")
except Exception as e:
print("[Slash] Sync failed:", repr(e))
# ---------- Auto-discover extensions ----------
modules_path = pathlib.Path(__file__).parent / "modules"
extensions = []
for folder in modules_path.iterdir():
if not folder.is_dir():
continue
# skip non-cog helpers under modules/common
if folder.name == "common":
continue
for file in folder.glob("*.py"):
if file.name == "__init__.py":
continue
extensions.append(f"modules.{folder.name}.{file.stem}")
def _install_signal_handlers(loop, bot_obj):
def _graceful(*_):
loop.create_task(bot_obj.close())
for s in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(s, _graceful)
except NotImplementedError:
pass # Windows
async def main():
print(f"[STARTUP] ShaiWatcher booting v{VERSION}")
async with bot:
for ext in extensions:
try:
await bot.load_extension(ext)
print(f"[Modules] Loaded: {ext}")
except Exception as e:
print(f"[Modules] Failed to load {ext}:", repr(e))
loop = asyncio.get_running_loop()
_install_signal_handlers(loop, bot)
await bot.start(TOKEN)
if __name__ == "__main__":
asyncio.run(main())

156
data_manager.py Normal file
View File

@ -0,0 +1,156 @@
# data_manager.py
import json
import threading
import shutil
import os
import time
from typing import Callable, Any
class DataManager:
def __init__(self, json_path: str):
self.json_path = json_path
self.lock = threading.Lock()
self._data = self._load()
def _default_payload(self):
return {
'agreed_rules': [],
'agreed_engagement': [],
'agreed_nickname': [],
'nick_same_confirmed': [],
'nick_nudged': [],
'nick_dm_map': [],
'pirates': [],
'modlog': [],
'reports': [],
'encounters': [],
'vc_channels': [],
'user_cards': [],
'pirates_list_posts': [],
'spicepay_prefs': [],
'nick_verified': [],
'nick_claim_pending': [],
'nick_reviews': [],
'rr_msg_channels': [],
'_counters': {}, # key -> int (metrics)
'_events_seen': {}, # optional (kept for other uses)
'_counter_last_ts': {}, # key -> last increment unix_ts (timelock)
}
def _load(self):
try:
with open(self.json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("root is not an object")
data.setdefault('_counters', {})
data.setdefault('_events_seen', {})
data.setdefault('_counter_last_ts', {})
return data
except FileNotFoundError:
default = self._default_payload()
self._save(default)
return default
except Exception:
# Backup the broken file if it exists, then start fresh
try:
if os.path.exists(self.json_path):
shutil.copy2(self.json_path, self.json_path + ".corrupt.bak")
except Exception:
pass
default = self._default_payload()
self._save(default)
return default
def _safe_write(self, data: dict):
os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
tmp = self.json_path + ".tmp"
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4, ensure_ascii=False)
if os.path.exists(self.json_path):
try:
shutil.copy2(self.json_path, self.json_path + ".bak")
except Exception:
pass
os.replace(tmp, self.json_path)
def _save(self, data: dict):
self._safe_write(data)
# ------------- list helpers -------------
def get(self, category: str):
with self.lock:
return list(self._data.get(category, []))
def add(self, category: str, item: Any):
with self.lock:
self._data.setdefault(category, []).append(item)
self._save(self._data)
def remove(self, category: str, predicate: Callable[[Any], bool]):
with self.lock:
arr = self._data.get(category, [])
self._data[category] = [i for i in arr if not predicate(i)]
self._save(self._data)
def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool:
with self.lock:
arr = self._data.get(category, [])
for idx, item in enumerate(arr):
if predicate(item):
new_item = dict(item)
new_item = updater(new_item) or new_item
arr[idx] = new_item
self._data[category] = arr
self._save(self._data)
return True
return False
# ------------- counters (plain) -------------
def incr_counter(self, key: str, by: int = 1) -> int:
with self.lock:
c = self._data.setdefault('_counters', {})
c[key] = int(c.get(key, 0)) + int(by)
self._save(self._data)
return c[key]
def get_counter(self, key: str) -> int:
with self.lock:
return int(self._data.get('_counters', {}).get(key, 0))
def get_all_counters(self, prefix: str = "") -> dict[str, int]:
with self.lock:
c = dict(self._data.get('_counters', {}))
return {k: v for k, v in c.items() if (not prefix or k.startswith(prefix))}
# ------------- counters (timelocked) -------------
def incr_counter_timelocked(self, counter_key: str, window_sec: float = 1.0) -> int | None:
"""
Increment `counter_key` at most once per `window_sec`.
Returns the new value if incremented, or None if suppressed by the timelock.
"""
now = time.time()
with self.lock:
last_map = self._data.setdefault('_counter_last_ts', {})
last = float(last_map.get(counter_key, 0.0))
if now - last < float(window_sec):
# within lock window -> ignore
return None
# increment and stamp
counters = self._data.setdefault('_counters', {})
counters[counter_key] = int(counters.get(counter_key, 0)) + 1
last_map[counter_key] = now
# opportunistic pruning for very old stamps (keeps file smaller)
if len(last_map) > 5000:
cutoff = now - (window_sec * 60)
for k in list(last_map.keys()):
try:
if float(last_map.get(k, 0.0)) < cutoff:
last_map.pop(k, None)
except Exception:
last_map.pop(k, None)
self._save(self._data)
return counters[counter_key]

148
dev/offline_preview.py Normal file
View 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())

View File

@ -0,0 +1,63 @@
[DEFAULT]
### Reaction gating messages (✅ reactions)
rules_message_id = # DISCORD RULES MESSAGE ID
engagement_message_id = # DISCORD EoR MESSAGE ID
nickname_message_id = # DISCORD NICKNAME MESSAGE ID
### Roles
rules_role_id = # RULES ACCEPTANCE ROLE ID
engagement_role_id = # RoE ACCEPTANCE ROLE ID
full_access_role_id = # FULL ACCESS ROLE ID
# Moderator roles (IDs)
admin_role_id = # AMIN ROLE ID
field_mod_role_id = # IN-GAME MOD ROLE ID
intel_mod_role_id = # INTELLIGENCE MOD ROLE ID
moderator_role_id = # REGULAR MOD ROLE ID
### Channels
# for approving pirate reports
mod_channel_id = # MODERATOR REVIEWS CHANNEL ID
# for join/nickname logs
modlog_channel_id = # MODLOG CHANNEL ID
# where user cards live
userslist_channel_id = # USERSLIST CHANNEL ID
# optional public notifications
report_channel_id = # PUBLIC REPORTS CHANNEL ID
# pirates list channel
pirates_list_channel_id = # PIRATES LIST CHANNEL ID
### Auto-VC settings
trigger_channel_id = # VC TRIGGER CHANNEL ID
auto_vc_category_id = # AUTO VC CHANNEL CATEGORY ID
# Prefix to created voice channels
vc_name_prefix = DD Crew
# Seconds before an abandoned voice channel is deleted
auto_vc_cleanup_delay = 30
# Pirate threat calculations weights
threat_w_kill = 0.30
threat_w_destruction = 0.40
threat_w_group = 0.20
threat_w_skill = 0.10
threat_group_threshold = 3
threat_min_samples_for_stats = 3
data_file = data/data.json
release_version = false
nick_nudge_loop_enabled = false
home_guild_id = # DISCORD SERVER/GUILD ID
user_cards_cron_enabled = true
# Spice Refinery Owner Processing Fee in %
spicepay_lsr_cut_percent = 10
# Default participation weight
spicepay_base_weight = 25
# Carrier owner weight bonus
spicepay_carrier_bonus = 12.5
# Crawler owner weight bonus
spicepay_crawler_bonus = 12.5
# Optional emojis
emoji_melange_id = 1401965356775510210
emoji_sand_id = 1401965308805255310
emoji_carrier_crawler_id = 1402285453037666386

63
mod_perms.py Normal file
View File

@ -0,0 +1,63 @@
# mod_perms.py
import re
import discord
from discord.ext import commands
from modules.common.settings import cfg # ENV-first config helper
def _parse_ids(raw: str):
ids = []
if not raw:
return ids
for tok in re.split(r'[,\s]+', raw.strip()):
if not tok:
continue
try:
ids.append(int(tok))
except Exception:
pass
return ids
def get_mod_role_ids(bot: commands.Bot):
# Read from ENV/INI via helper; allow comma-separated lists in any field
reader = cfg(bot)
keys = ["admin_role_id", "field_mod_role_id", "intel_mod_role_id", "moderator_role_id"]
collected = []
for k in keys:
collected.extend(_parse_ids(reader.get(k, "")))
# dedupe while preserving order
seen = set()
unique = []
for i in collected:
if i not in seen:
seen.add(i)
unique.append(i)
return unique
def is_moderator_member(member: discord.Member, bot: commands.Bot) -> bool:
if not isinstance(member, discord.Member):
return False
if member.guild_permissions.administrator:
return True
mod_ids = set(get_mod_role_ids(bot))
return any(r.id in mod_ids for r in member.roles)
def is_moderator_userid(guild: discord.Guild, user_id: int, bot: commands.Bot) -> bool:
m = guild.get_member(user_id)
return is_moderator_member(m, bot) if m else False
async def require_mod_ctx(ctx: commands.Context, msg="You dont have permission to use this."):
if not is_moderator_member(ctx.author, ctx.bot):
await ctx.reply(msg)
return False
return True
async def require_mod_interaction(interaction: discord.Interaction, msg="This command is restricted to moderators."):
user = interaction.user
if isinstance(user, discord.Member) and is_moderator_member(user, interaction.client):
return True
# Only send a response if we havent already
if not interaction.response.is_done():
await interaction.response.send_message(msg, ephemeral=True)
else:
await interaction.followup.send(msg, ephemeral=True)
return False

View File

168
modules/admin/shaiadmin.py Normal file
View 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 dont have permission to use `/shaiadmin`.", ephemeral=True)
else:
await inter.followup.send("You dont 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))

View File

281
modules/auto_vc/auto_vc.py Normal file
View File

@ -0,0 +1,281 @@
# modules/auto_vc/auto_vc.py
import asyncio
import time
import discord
from discord.ext import commands
from discord import app_commands
from modules.common.settings import cfg # ENV-first config helper
from mod_perms import require_mod_ctx, require_mod_interaction # <- use project mod perms
def now() -> float:
return time.time()
class AutoVCCog(commands.Cog):
"""
Auto-VC:
When someone joins the trigger voice channel, create a new VC under the target category,
name it "{prefix} N", and move the member there.
When an auto-VC is empty for `delay` seconds, delete it and renumber the remaining ones.
Only channels created by this cog are managed (tracked in data_manager['vc_channels']).
Admin commands:
/avc_status -> show current state
/avc_cleanup_now -> [MOD] run a cleanup/renumber pass now
/avc_renumber -> [MOD] renumber without deleting
"""
def __init__(self, bot):
self.bot = bot
r = cfg(bot)
# Config (ENV/INI via helper; safe defaults)
self.trigger_id = r.int('trigger_channel_id', 0)
self.category_id = r.int('auto_vc_category_id', 0)
self.prefix = r.get('vc_name_prefix', 'Room')
self.delay = r.int('auto_vc_cleanup_delay', 30)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
# State
self.empty_since: dict[int, float] = {} # channel_id -> ts when became empty
self._vc_cooldowns: dict[int, float] = {} # user_id -> ts last created (anti-spam)
self._create_lock = asyncio.Lock()
self._ops_lock = asyncio.Lock() # serialize admin ops vs sweeper
# Background sweeper
self._task = asyncio.create_task(self._sweeper())
# ------------- utilities -------------
def cog_unload(self):
try:
self._task.cancel()
except Exception:
pass
def _prefixed(self, num: int) -> str:
return f"{self.prefix.strip()} {num}"
def _vc_records(self, guild_id: int):
"""Return list of tracked records for this guild from persistent store."""
return [r for r in self.bot.data_manager.get('vc_channels') if r.get('guild_id') == guild_id]
def _find_record(self, guild_id: int, channel_id: int):
for r in self._vc_records(guild_id):
if r.get('channel_id') == channel_id:
return r
return None
async def _log(self, guild: discord.Guild, msg: str):
if not self.modlog_channel_id:
print(f"[AutoVC][{guild.name}] {msg}")
return
ch = guild.get_channel(self.modlog_channel_id)
if ch:
try:
await ch.send(msg)
except Exception:
print(f"[AutoVC][{guild.name}] (fallback) {msg}")
else:
print(f"[AutoVC][{guild.name}] {msg}")
async def _renumber(self, guild: discord.Guild):
"""Rename tracked channels to {prefix} 1..N in stable order (by creation_ts)."""
recs = sorted(self._vc_records(guild.id), key=lambda r: r.get('created_ts', 0))
for i, rec in enumerate(recs, start=1):
ch = guild.get_channel(rec['channel_id'])
if not ch:
# prune dead record
self.bot.data_manager.remove('vc_channels', lambda x: x.get('channel_id') == rec['channel_id'])
continue
desired = self._prefixed(i)
if ch.name != desired:
try:
await ch.edit(name=desired, reason="Auto-VC renumber")
except Exception as e:
print("[auto_vc] rename failed:", repr(e))
async def _cleanup_pass(self, guild: discord.Guild):
"""Delete empty tracked channels that exceeded delay and renumber."""
if not self.category_id:
return
cat = guild.get_channel(self.category_id)
if not cat:
return
tracked_ids = {r['channel_id'] for r in self._vc_records(guild.id)}
now_ts = now()
to_delete: list[discord.VoiceChannel] = []
# Mark empties & collect deletions
for ch in cat.voice_channels:
if ch.id not in tracked_ids:
continue # unmanaged room
if len(ch.members) == 0:
started = self.empty_since.get(ch.id)
if started is None:
self.empty_since[ch.id] = now_ts
elif now_ts - started >= self.delay:
to_delete.append(ch)
else:
self.empty_since.pop(ch.id, None)
# Delete idle channels
for ch in to_delete:
try:
await ch.delete(reason=f"Auto-VC idle > {self.delay}s")
await self._log(guild, f"🗑️ Deleted idle room: `{ch.name}`")
except Exception as e:
print("[auto_vc] delete failed:", repr(e))
# purge record and emptiness stamp
self.bot.data_manager.remove('vc_channels', lambda r, cid=ch.id: r.get('channel_id') == cid)
self.empty_since.pop(ch.id, None)
# purge records for channels that vanished by other means
for rec in list(self._vc_records(guild.id)):
if not guild.get_channel(rec['channel_id']):
self.bot.data_manager.remove('vc_channels', lambda r, cid=rec['channel_id']: r.get('channel_id') == cid)
if to_delete:
await self._renumber(guild)
# ------------- background worker -------------
async def _sweeper(self):
await self.bot.wait_until_ready()
while not self.bot.is_closed():
try:
# Serialize with admin ops
async with self._ops_lock:
for guild in self.bot.guilds:
await self._cleanup_pass(guild)
except Exception as e:
print("[auto_vc] sweeper loop error:", repr(e))
await asyncio.sleep(30)
# ------------- channel creation -------------
async def _spawn_and_move(self, member: discord.Member):
guild = member.guild
if not self.category_id:
await self._log(guild, "⚠️ auto_vc_category_id not configured; cannot create rooms.")
return
cat = guild.get_channel(self.category_id)
if not cat:
await self._log(guild, "⚠️ auto_vc_category_id not found; cannot create rooms.")
return
async with self._create_lock:
# Determine next index based on *tracked* channels
recs = sorted(self._vc_records(guild.id), key=lambda r: r.get('created_ts', 0))
next_index = len(recs) + 1
name = self._prefixed(next_index)
# Create room
try:
new_ch = await cat.create_voice_channel(name, reason="Auto-VC spawn")
except discord.Forbidden:
await self._log(guild, "❌ Missing permission to create voice channels in the category.")
return
except Exception as e:
await self._log(guild, f"❌ Failed to create voice channel: {e}")
return
# Persist record
self.bot.data_manager.add('vc_channels', {
'guild_id': guild.id,
'channel_id': new_ch.id,
'created_ts': now()
})
# Move the user
try:
await member.move_to(new_ch, reason="Auto-VC move")
except discord.Forbidden:
await self._log(guild, "⚠️ I need **Move Members** and **Connect** permissions to move users.")
except Exception as e:
await self._log(guild, f"⚠️ Could not move {member} into `{name}`: {e}")
# Start its idle timer if it immediately empties
if len(new_ch.members) == 0:
self.empty_since[new_ch.id] = now()
# ------------- core flow -------------
@commands.Cog.listener()
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
guild = member.guild
# Create on trigger join (with 5s per-user cooldown)
if self.trigger_id and after.channel and after.channel.id == self.trigger_id:
last = self._vc_cooldowns.get(member.id, 0.0)
if now() - last < 5.0:
return
self._vc_cooldowns[member.id] = now()
try:
await self._spawn_and_move(member)
except Exception as e:
print("[auto_vc] spawn/move failed:", repr(e))
# Mark empties immediately on leave
if before.channel and self.category_id:
ch = before.channel
if ch.category_id == self.category_id:
rec = self._find_record(guild.id, ch.id)
if rec and len(ch.members) == 0:
self.empty_since[ch.id] = now()
# ------------- admin commands -------------
@app_commands.command(name="avc_status", description="Show Auto-VC status for this guild")
async def avc_status(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
g = interaction.guild
recs = sorted(self._vc_records(g.id), key=lambda r: r.get('created_ts', 0))
lines = [
f"Trigger: <#{self.trigger_id or 0}> | Category: <#{self.category_id or 0}> | Prefix: `{self.prefix}` | Delay: {self.delay}s"
]
for idx, rec in enumerate(recs, start=1):
ch = g.get_channel(rec['channel_id'])
if not ch:
state = "missing"
name = f"(deleted #{idx})"
else:
name = ch.name
state = f"{len(ch.members)} inside" if len(ch.members) else "empty"
t = self.empty_since.get(rec['channel_id'])
tail = f" | idle {int(now()-t)}s" if t and (not ch or (ch and not ch.members)) else ""
lines.append(f"- #{idx}: {name}{state}{tail}")
msg = "Auto-VC status:\n" + "\n".join(lines) if lines else "No Auto-VC rooms tracked."
await interaction.response.send_message(msg)
@app_commands.command(name="avc_cleanup_now", description="[MOD] Run an immediate cleanup pass (delete idle rooms & renumber)")
async def avc_cleanup_now(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
await interaction.response.defer(ephemeral=True)
async with self._ops_lock:
await self._cleanup_pass(interaction.guild)
await self._log(interaction.guild, f"🧹 Cleanup pass invoked by {interaction.user.mention}")
await interaction.followup.send("Cleanup pass complete.", ephemeral=True)
@app_commands.command(name="avc_renumber", description="[MOD] Force a renumber of tracked rooms")
async def avc_renumber(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
await interaction.response.defer(ephemeral=True)
async with self._ops_lock:
await self._renumber(interaction.guild)
await self._log(interaction.guild, f"🔢 Renumber invoked by {interaction.user.mention}")
await interaction.followup.send("Renumbered.", ephemeral=True)
async def setup(bot):
await bot.add_cog(AutoVCCog(bot))

View 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}")

View File

@ -0,0 +1,24 @@
# Accept/approve emoji set used across the bot.
# Works for both unicode and custom server emoji.
# Unicode emoji that should count as "accept"
ACCEPT_UNICODE = {"", "🫡", "❤️"}
# Custom emoji short names that should count as "accept"
# Add names (not the <:name:id> literal) for any server emoji you want.
ACCEPT_CUSTOM_NAMES = {"diverOK"}
def is_accept(emoji) -> bool:
"""
Return True if the given emoji should count as an 'accept' reaction.
Compatible with Reaction.emoji and RawReactionActionEvent.emoji.
"""
try:
# unicode path
if str(emoji) in ACCEPT_UNICODE:
return True
# custom emoji path (has a .name)
name = getattr(emoji, "name", None)
return name in ACCEPT_CUSTOM_NAMES
except Exception:
return False

495
modules/common/settings.py Normal file
View 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)

View File

View 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
View File

781
modules/dd/dd_loot_table.py Normal file
View 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))

View File

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

View File

View File

@ -0,0 +1,533 @@
# modules/nick_nudge/nick_nudge.py
import asyncio
import time
from typing import Optional, Tuple
from collections import defaultdict
import discord
from discord.ext import commands
from discord import app_commands
from mod_perms import is_moderator_userid, require_mod_interaction
from modules.common.emoji_accept import is_accept
from modules.common.settings import cfg # ENV-first config helper
CHECK = '' # approved/verified
CROSS = '' # reject / no
PENDING = '✔️' # heavy check mark = pending claim
ACCEPT = {CHECK, '🫡'}
NO_MENTIONS = discord.AllowedMentions.none()
# Per-user in-process lock to prevent duplicate reviews from concurrent reactions
_user_locks = defaultdict(asyncio.Lock)
def _ts_rel(ts: Optional[float] = None) -> str:
"""Discord relative timestamp like <t:12345:R>."""
if ts is None:
ts = time.time()
return f"<t:{int(ts)}:R>"
class NickNudgeCog(commands.Cog):
"""
Nickname review flow:
- Atomic transition to 'pending' and open exactly ONE review.
- Mods: approve -> mark verified; reject -> clear claim.
- If a verified user changes their nickname, verification is revoked automatically.
Data keys used in data_manager:
agreed_nickname: [user_id]
nick_claim_pending: [user_id]
nick_verified: [user_id]
nick_reviews: [{ message_id, guild_id, user_id, status, ... }]
nick_verified_name: [{ guild_id, user_id, nick, ts }]
"""
def __init__(self, bot):
self.bot = bot
r = cfg(bot)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
self.mod_channel_id = r.int('mod_channel_id', 0)
self.loop_enabled = r.bool('nick_nudge_loop_enabled', False)
self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None
# ---------- utils ----------
def cog_unload(self):
try:
if self._task:
self._task.cancel()
except Exception:
pass
async def _modlog(self, guild: discord.Guild, content: str):
if not self.modlog_channel_id:
return
ch = guild.get_channel(self.modlog_channel_id)
if ch:
try:
await ch.send(content, allowed_mentions=NO_MENTIONS)
except Exception:
pass
self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content})
async def _find_last_nick_change(self, guild: discord.Guild, member: discord.Member) -> Tuple[Optional[str], Optional[str]]:
try:
async for entry in guild.audit_logs(limit=10, action=discord.AuditLogAction.member_update):
if entry.target.id != member.id or not entry.changes:
continue
before_n = getattr(entry.changes.before, 'get', lambda *_: None)('nick')
after_n = getattr(entry.changes.after, 'get', lambda *_: None)('nick')
if before_n is not None or after_n is not None:
return before_n, after_n
except Exception:
pass
return None, None
# ---------- atomic entry point used by all claim sources ----------
async def ensure_pending_and_maybe_open(self, guild: discord.Guild, member: discord.Member, source: str):
"""
Atomically:
- set pending (idempotent)
- open ONE review if this is the first transition to pending
Prevents duplicate reviews when multiple reactions/commands fire.
"""
if not guild or not self.mod_channel_id or member.bot:
return
newly_pending = False
lock = _user_locks[member.id]
async with lock:
dm = self.bot.data_manager
# If a pending review already exists, bail out
for r in dm.get('nick_reviews'):
if r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending':
return
# Mark "agreed" and flip to pending if not already
if member.id not in dm.get('agreed_nickname'):
dm.add('agreed_nickname', int(member.id))
dm.remove('nick_verified', lambda x: x == member.id)
if member.id not in dm.get('nick_claim_pending'):
dm.add('nick_claim_pending', int(member.id))
newly_pending = True
if newly_pending:
try:
await self.start_nick_review(guild, member, source=source)
except Exception:
# Roll back pending on failure so the user can try again
try:
dm = self.bot.data_manager
dm.remove('nick_claim_pending', lambda x: x == member.id)
except Exception:
pass
# ---------- public API ----------
async def start_nick_review(self, guild: discord.Guild, member: discord.Member, source: str = "claim"):
if not guild or not self.mod_channel_id:
return
mod_ch = guild.get_channel(self.mod_channel_id)
if not mod_ch:
return
before_n, _ = await self._find_last_nick_change(guild, member)
now_ts = int(time.time())
title = "📝 **Nickname Verification Request**"
who = f"User: {member.mention} (`{member.id}`)"
change = f"Claimed {_ts_rel(now_ts)}"
from_to = f"From: {repr(before_n) if before_n is not None else 'unknown'} → To: {repr(member.nick) if member.nick else 'None'}"
method = f"Method: {'/nick_same' if source == 'nick_same' else 'reaction'}"
instructions = "Moderators: react ✅ to **approve** or ❌ to **reject**."
content = f"{title}\n{who}\n{from_to}\n{method}\n{change}\n\n{instructions}"
try:
msg = await mod_ch.send(content, allowed_mentions=NO_MENTIONS)
await msg.add_reaction(CHECK)
await msg.add_reaction(CROSS)
except Exception:
return
self.bot.data_manager.add('nick_reviews', {
'message_id': int(msg.id),
'guild_id': int(guild.id),
'user_id': int(member.id),
'before_nick': before_n if before_n is None or isinstance(before_n, str) else str(before_n),
'claimed_nick': member.nick if member.nick else None,
'status': 'pending',
'source': source,
'ts': now_ts
})
await self._modlog(guild, f"🔎 Nickname review opened for {member.mention}{method}{_ts_rel(now_ts)}.")
# ---------- DM nudge loop ----------
async def _nudge_loop(self):
await self.bot.wait_until_ready()
while not self.bot.is_closed():
try:
now_t = time.time()
for guild in self.bot.guilds:
for member in guild.members:
if member.bot or not member.joined_at:
continue
if (now_t - member.joined_at.timestamp()) < 24 * 3600:
continue
dm = self.bot.data_manager
if (member.nick and member.nick.strip()):
continue
if member.id in dm.get('nick_verified') or member.id in dm.get('nick_claim_pending'):
continue
if member.id in dm.get('nick_nudged'):
continue
try:
dmchan = await member.create_dm()
msg = await dmchan.send(
"Hey! On this server we require your **server nickname** to match your in-game character name.\n\n"
"If your default Discord display name is already identical to your in-game name, react ✅ **or 🫡** below "
"or run `/nick_same`. Otherwise, please set your **server nickname** to your in-game name and react ✅ **or 🫡**."
)
await msg.add_reaction(CHECK)
self.bot.data_manager.add('nick_dm_map', {
'message_id': int(msg.id),
'user_id': int(member.id),
'guild_id': int(guild.id),
'ts': now_t
})
self.bot.data_manager.add('nick_nudged', int(member.id))
await self._modlog(guild, f"📨 Sent nickname nudge to {member.mention}")
except Exception:
pass
except Exception:
pass
await asyncio.sleep(1800)
# ---------- listeners ----------
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member):
"""Revoke verification if a verified user changes their nick to anything."""
if before.bot or before.guild != after.guild:
return
if before.nick == after.nick:
return
dm = self.bot.data_manager
# Only act if the user is currently verified
if before.id in dm.get('nick_verified'):
dm.remove('nick_verified', lambda x: x == before.id)
dm.remove('nick_claim_pending', lambda x: x == before.id)
try:
dm.remove('nick_verified_name', lambda r: r.get('guild_id') == before.guild.id and r.get('user_id') == before.id)
except Exception:
pass
dm.add('nick_verified_name', {
'guild_id': int(before.guild.id),
'user_id': int(before.id),
'nick': after.nick if after.nick else None,
'ts': int(time.time())
})
try:
await self._modlog(after.guild, f"⚠️ {after.mention} changed nickname; **verification revoked**. They must re-claim for a new review.")
except Exception:
pass
rr = self.bot.get_cog('ReactionRoleCog')
if rr:
try:
await rr.maybe_apply_full_access(after)
except Exception:
pass
cards = self.bot.get_cog('UserCardsCog')
if cards:
try:
await cards.refresh_card(after)
except Exception:
pass
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
# 1) Handle DM nudge confirmations (user reacts with an accept in DM)
if payload.guild_id is None and is_accept(payload.emoji) and payload.user_id != self.bot.user.id:
entry = next((m for m in self.bot.data_manager.get('nick_dm_map') if m['message_id'] == payload.message_id), None)
if not entry:
return
guild = self.bot.get_guild(entry['guild_id'])
member = guild.get_member(entry['user_id']) if guild else None
if not member:
return
try:
await self.ensure_pending_and_maybe_open(guild, member, source="nick_same")
except Exception:
pass
self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id)
rr = self.bot.get_cog('ReactionRoleCog')
if rr:
try:
await rr.maybe_apply_full_access(member)
except Exception:
pass
cards = self.bot.get_cog('UserCardsCog')
if cards:
try:
await cards.refresh_card(member)
except Exception:
pass
return
# 2) Handle moderator review reactions in mod channel
if payload.guild_id and str(payload.emoji) in (CHECK, CROSS) and payload.user_id != self.bot.user.id:
if payload.channel_id != self.mod_channel_id or not self.mod_channel_id:
return
guild = self.bot.get_guild(payload.guild_id)
if not guild:
return
if not is_moderator_userid(guild, payload.user_id, self.bot):
return
reviews = self.bot.data_manager.get('nick_reviews')
review = next((r for r in reviews if r.get('message_id') == payload.message_id and r.get('guild_id') == guild.id), None)
if not review or review.get('status') != 'pending':
return
member = guild.get_member(int(review['user_id']))
if not member:
self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'closed_missing'}), r)[1])
return
try:
ch = self.bot.get_channel(payload.channel_id)
msg = await ch.fetch_message(payload.message_id)
except Exception:
msg = None
dm = self.bot.data_manager
now_ts = int(time.time())
approver = f"<@{payload.user_id}>"
if str(payload.emoji) == CHECK:
if member.id not in dm.get('agreed_nickname'):
dm.add('agreed_nickname', int(member.id))
dm.remove('nick_claim_pending', lambda x: x == member.id)
if member.id not in dm.get('nick_verified'):
dm.add('nick_verified', int(member.id))
try:
dm.remove('nick_verified_name', lambda r: r.get('guild_id') == guild.id and r.get('user_id') == member.id)
except Exception:
pass
dm.add('nick_verified_name', {
'guild_id': int(guild.id),
'user_id': int(member.id),
'nick': member.nick if member.nick else None,
'ts': now_ts
})
dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'approved', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
if msg:
try:
await msg.clear_reactions()
except Exception:
pass
try:
await msg.edit(content=f"✅ **Nickname Approved** for {member.mention} by {approver}{_ts_rel(now_ts)}")
except Exception:
pass
await self._modlog(guild, f"✅ Nickname **verified** for {member.mention} by {approver}{_ts_rel(now_ts)}.")
rr = self.bot.get_cog('ReactionRoleCog')
if rr:
try:
await rr.maybe_apply_full_access(member)
except Exception:
pass
cards = self.bot.get_cog('UserCardsCog')
if cards:
try:
await cards.refresh_card(member)
except Exception:
pass
else:
dm.remove('agreed_nickname', lambda x: x == member.id)
dm.remove('nick_claim_pending', lambda x: x == member.id)
dm.remove('nick_verified', lambda x: x == member.id)
dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'rejected', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
if msg:
try:
await msg.clear_reactions()
except Exception:
pass
try:
await msg.edit(content=f"❌ **Nickname Rejected** for {member.mention} by {approver}{_ts_rel(now_ts)}")
except Exception:
pass
await self._modlog(guild, f"❌ Nickname **rejected** for {member.mention} by {approver}{_ts_rel(now_ts)}.")
rr = self.bot.get_cog('ReactionRoleCog')
if rr:
try:
await rr.maybe_apply_full_access(member)
except Exception:
pass
cards = self.bot.get_cog('UserCardsCog')
if cards:
try:
await cards.refresh_card(member)
except Exception:
pass
# ---------- Mod commands to manipulate nickname reviews ----------
@app_commands.command(name="clear_nick_reviews", description="[MOD] Delete all PENDING nickname review records for this server.")
async def clear_nick_reviews(self, interaction: discord.Interaction):
"""Moderator-only. Clears all 'pending' entries in data_manager['nick_reviews'] for this guild."""
# Must be used in a guild
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
# Moderator permission check (your existing gate)
if not await require_mod_interaction(interaction):
return # require_mod_interaction already responded
dm = self.bot.data_manager
# Count pending records for this guild
pending = [
r for r in dm.get('nick_reviews')
if r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending'
]
count = len(pending)
# Remove pending records
if count:
dm.remove(
'nick_reviews',
lambda r: r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending'
)
# Modlog + ephemeral confirmation
try:
await self._modlog(
interaction.guild,
f"🧹 {interaction.user.mention} cleared **{count}** pending nickname review(s)."
)
except Exception:
pass
await interaction.response.send_message(
f"Cleared **{count}** pending nickname review{'s' if count != 1 else ''}.",
ephemeral=True
)
@app_commands.command(name="recreate_nick_reviews", description="[MOD] Scan and recreate any missing pending nickname reviews for this server.")
async def recreate_nick_reviews(self, interaction: discord.Interaction):
"""Moderator-only bulk fixer for 'grey checkmark' users (claimed but no pending review)."""
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction):
return # already replied
dm = self.bot.data_manager
guild = interaction.guild
agreed = set(dm.get('agreed_nickname'))
verified = set(dm.get('nick_verified'))
# Build a quick lookup of existing pending reviews
pending_reviews = {
(r.get('guild_id'), r.get('user_id'))
for r in dm.get('nick_reviews')
if r.get('status') == 'pending'
}
to_fix = []
for uid in agreed:
# Needs a review if not verified and no pending review exists
if (guild.id, uid) not in pending_reviews and uid not in verified:
m = guild.get_member(uid)
if m and not m.bot:
to_fix.append(m)
fixed = 0
skipped = 0
for member in to_fix:
try:
# Clear stale pending so the atomic method will transition and open a new one
dm.remove('nick_claim_pending', lambda x, _uid=member.id: x == _uid)
await self.ensure_pending_and_maybe_open(guild, member, source="recreate")
fixed += 1
except Exception:
skipped += 1
try:
await self._modlog(guild, f"🛠️ {interaction.user.mention} recreated **{fixed}** nickname review(s); skipped **{skipped}**.")
except Exception:
pass
await interaction.response.send_message(
f"Recreated **{fixed}** review(s); skipped **{skipped}**.",
ephemeral=True
)
@app_commands.command(name="recreate_nick_review", description="[MOD] Recreate a missing pending nickname review for one user.")
@app_commands.describe(user="Member to recreate review for")
async def recreate_nick_review(self, interaction: discord.Interaction, user: discord.Member):
"""Moderator-only single-user fixer."""
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction):
return # already replied
guild = interaction.guild
dm = self.bot.data_manager
# If already verified, nothing to do
if user.id in dm.get('nick_verified'):
return await interaction.response.send_message("User is already verified — no review needed.", ephemeral=True)
# If a pending review already exists, nothing to do
has_pending = any(
r.get('guild_id') == guild.id and r.get('user_id') == user.id and r.get('status') == 'pending'
for r in dm.get('nick_reviews')
)
if has_pending:
return await interaction.response.send_message("A pending review already exists for this user.", ephemeral=True)
# If they never agreed/claimed, mark claim now so the state is consistent
if user.id not in dm.get('agreed_nickname'):
dm.add('agreed_nickname', int(user.id))
# Clear stale pending flag, then open atomically
dm.remove('nick_claim_pending', lambda x: x == user.id)
try:
await self.ensure_pending_and_maybe_open(guild, user, source="recreate")
except Exception:
return await interaction.response.send_message("Failed to create the review (see logs).", ephemeral=True)
try:
await self._modlog(guild, f"🛠️ {interaction.user.mention} recreated a nickname review for {user.mention}.")
except Exception:
pass
await interaction.response.send_message("Recreated the nickname review for that user.", ephemeral=True)
async def setup(bot):
await bot.add_cog(NickNudgeCog(bot))

View File

View File

@ -0,0 +1,224 @@
# modules/pirate_cards/pirate_cards.py
import asyncio
import discord
from discord.ext import commands
from discord import app_commands
from datetime import datetime
from mod_perms import require_mod_ctx, require_mod_interaction
from modules.common.settings import cfg # ENV-first config helper
class PirateCardsCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
r = cfg(bot)
# IDs / config (ENV -> optional INI fallback)
self.pirates_channel_id = r.int('pirates_list_channel_id', 0)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
# thresholds / samples (with defaults)
self.group_threshold = r.int('threat_group_threshold', 3)
self.min_samples = r.int('threat_min_samples_for_stats', 3)
# safe posting (dont ping)
self._no_mentions = discord.AllowedMentions.none()
# serialize rebuilds per guild
self._locks: dict[int, asyncio.Lock] = {}
# -------- internals / helpers --------
def _lock_for(self, guild_id: int) -> asyncio.Lock:
self._locks.setdefault(guild_id, asyncio.Lock())
return self._locks[guild_id]
@staticmethod
def _esc(s: str) -> str:
"""Escape markdown & neutralize mentions for any user-sourced strings we show."""
safe = discord.utils.escape_markdown(str(s))
return safe.replace("@", "@\u200b")
def _color_for_threat(self, threat: int, enc_count: int) -> discord.Color:
"""
Gradient:
- If too few samples: dark gray (unknown)
- Else 0 -> green (0,255,0), 100 -> red (255,0,0)
"""
if enc_count < self.min_samples:
return discord.Color.dark_gray()
t = max(0, min(100, int(threat))) / 100.0
r = int(round(255 * t))
g = int(round(255 * (1.0 - t)))
return discord.Color.from_rgb(r, g, 0)
def _bucket(self, rate: float, samples: int) -> str:
if samples < self.min_samples:
return "unknown"
if rate <= 0:
return "never"
if rate <= 0.25:
return "rarely"
if rate <= 0.60:
return "sometimes"
if rate <= 0.85:
return "often"
return "always"
def _encounters_for(self, pirate: dict):
acct_l = str(pirate.get('account_name', '')).lower()
char_l = str(pirate.get('character_name', '')).lower()
out = []
for e in self.bot.data_manager.get('encounters'):
try:
ident = str(e.get('identifier', '')).lower()
if ident in (acct_l, char_l):
out.append(e)
except Exception:
continue
return out
def _get_card_record(self, account_lower: str):
for r in self.bot.data_manager.get('pirate_cards'):
if r.get('account_lower') == account_lower:
return r
return None
async def _modlog(self, guild: discord.Guild, content: str):
if not self.modlog_channel_id:
return
ch = guild.get_channel(self.modlog_channel_id)
if ch:
try:
await ch.send(content, allowed_mentions=self._no_mentions)
except Exception:
pass
async def _build_embed(self, pirate: dict) -> discord.Embed:
encs = self._encounters_for(pirate)
total = len(encs)
def _i(v, d=0):
try:
return int(v)
except Exception:
return d
group_rate = (sum(1 for e in encs if _i(e.get('group_size'), 0) >= self.group_threshold) / total) if total else 0.0
destroy_rate = (sum(1 for e in encs if bool(e.get('destruction'))) / total) if total else 0.0
# last encountered: date only (UTC)
last_date = ""
if total:
try:
last_ts = max(int(float(e.get('timestamp', 0))) for e in encs)
last_date = datetime.utcfromtimestamp(last_ts).strftime('%Y-%m-%d')
except Exception:
last_date = ""
groups_str = self._bucket(group_rate, total)
destr_str = self._bucket(destroy_rate, total)
# threat & color
threat = int(pirate.get('threat_level', 0))
color = self._color_for_threat(threat, total)
char = self._esc(pirate.get('character_name', 'Unknown'))
acct = self._esc(pirate.get('account_name', 'Unknown#00000'))
embed = discord.Embed(title=char, color=color)
embed.add_field(name="Account name", value=f"*{acct}*", inline=False)
embed.add_field(name="Threat", value=f"{threat}%", inline=True)
embed.add_field(name="In groups", value=groups_str, inline=True)
embed.add_field(name="Destructive", value=destr_str, inline=True)
embed.set_footer(text=f"Encounters: {total} | Last: {last_date}")
return embed
async def refresh_card_for_account(self, guild: discord.Guild, account_name: str):
"""Create or update a single pirate card by account name."""
acct_l = str(account_name or "").lower()
pirates = self.bot.data_manager.get('pirates')
p = next((x for x in pirates if str(x.get('account_name', '')).lower() == acct_l), None)
if not p:
return
channel = guild.get_channel(self.pirates_channel_id)
if not channel:
return
embed = await self._build_embed(p)
rec = self._get_card_record(acct_l)
if rec:
try:
msg = await channel.fetch_message(int(rec['message_id']))
await msg.edit(embed=embed)
return
except Exception:
# fall-through to recreate
pass
try:
msg = await channel.send(embed=embed, allowed_mentions=self._no_mentions)
self.bot.data_manager.add('pirate_cards', {
'account_lower': acct_l,
'message_id': msg.id
})
except Exception:
pass
async def delete_card_for_account(self, guild: discord.Guild, account_name: str):
"""Delete a pirate card by account and remove its record."""
acct_l = str(account_name or "").lower()
channel = guild.get_channel(self.pirates_channel_id)
rec = self._get_card_record(acct_l)
if rec and channel:
try:
msg = await channel.fetch_message(int(rec['message_id']))
await msg.delete()
except Exception:
pass
# ensure record is gone regardless
self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == acct_l)
async def update_on_edit(self, guild: discord.Guild, old_account: str, new_account: str):
"""When an account name changes, carry over the card mapping and refresh."""
old_l = str(old_account or "").lower()
new_l = str(new_account or "").lower()
rec = self._get_card_record(old_l)
if rec:
# replace mapping to new key
self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == old_l)
self.bot.data_manager.add('pirate_cards', {
'account_lower': new_l,
'message_id': rec['message_id']
})
await self.refresh_card_for_account(guild, new_account)
# -------- command (mod-gated via require_mod_ctx) --------
@app_commands.command(name="pirate_cards_rebuild", description="[MOD] Rebuild pirate cards for all known pirates")
async def pirate_cards_rebuild(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
ch = guild.get_channel(self.pirates_channel_id)
if not ch:
return await interaction.followup.send("Configured pirates_list_channel_id not found.", ephemeral=True)
count = 0
for p in self.bot.data_manager.get('pirates'):
try:
await self.refresh_card_for_account(guild, p.get('account_name', ''))
count += 1
except Exception:
continue
await interaction.followup.send(f"Rebuilt/updated {count} pirate cards.", ephemeral=True)
async def setup(bot):
await bot.add_cog(PirateCardsCog(bot))

View File

View File

@ -0,0 +1,719 @@
# modules/pirate_report/pirate_report.py
import re
import time
from datetime import datetime, timezone
from urllib.parse import urlparse
import discord
from discord.ext import commands
from discord import app_commands
from modules.common.settings import cfg
from mod_perms import (
is_moderator_member,
is_moderator_userid,
require_mod_ctx,
require_mod_interaction,
)
CHECK = ''
CROSS = ''
# --------------------- Helpers ---------------------
_ACCT_RE = re.compile(r'.+#\d{5}$')
DISCORD_MEDIA_HOSTS = {"cdn.discordapp.com", "media.discordapp.net"}
MEDIA_EXTS_IMAGE = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
MEDIA_EXTS_VIDEO = {".mp4", ".webm", ".mov"}
MEDIA_EXTS_ALL = MEDIA_EXTS_IMAGE | MEDIA_EXTS_VIDEO
def _acct_ok(s: str) -> bool:
return bool(_ACCT_RE.fullmatch(s.strip()))
def _now_utc_str() -> str:
return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
def _parse_bool(s: str) -> bool:
v = s.strip().lower()
if v in ('y', 'yes', 'true', 't', '1'):
return True
if v in ('n', 'no', 'false', 'f', '0'):
return False
raise ValueError("Please enter yes or no")
def _classify_discord_media(url: str):
"""
Return ('image'|'video', normalized_url) if valid Discord CDN media; else (None, reason).
We only accept direct CDN links so media can render inline without leaving the channel.
"""
try:
u = url.strip()
if not u:
return (None, "Empty URL.")
pr = urlparse(u)
if pr.scheme not in ("http", "https"):
return (None, "URL must start with http:// or https://")
host = pr.netloc.lower()
if host not in DISCORD_MEDIA_HOSTS:
return (None, "URL must be a **Discord media** link (cdn.discordapp.com or media.discordapp.net).")
path = pr.path or ""
dot = path.rfind(".")
if dot == -1:
return (None, "URL must end with a known media file extension.")
ext = path[dot:].lower()
if ext not in MEDIA_EXTS_ALL:
return (None, f"Unsupported media type `{ext}`. Allowed: images {sorted(MEDIA_EXTS_IMAGE)}, videos {sorted(MEDIA_EXTS_VIDEO)}.")
kind = "image" if ext in MEDIA_EXTS_IMAGE else "video"
return (kind, u)
except Exception:
return (None, "Invalid URL format.")
def _jump_url(guild_id: int, channel_id: int, message_id: int) -> str:
return f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}"
def _make_report_embed(title: str, color: discord.Color, report_dict: dict, include_status: bool = False):
e = discord.Embed(title=title, color=color, timestamp=datetime.utcnow())
e.add_field(name="Character", value=report_dict['character_name'], inline=False)
e.add_field(name="Account", value=report_dict['account_name'], inline=False)
e.add_field(name="Submitted by", value=f"<@{report_dict['submitter_id']}>", inline=False)
proof_url = (report_dict.get('proof_url') or "").strip()
proof_type = (report_dict.get('proof_type') or "").strip()
if include_status:
e.add_field(
name="Status",
value=report_dict.get('status_line', 'Pending'),
inline=False
)
# Show proof inline if it's an image; for videos we'll keep the URL in message content.
if proof_url and proof_type == 'image':
e.set_image(url=proof_url)
# Add a compact proof field for quick visibility (always safe to include)
e.add_field(
name="Proof",
value=proof_url if proof_url else "_No proof provided — strongly encouraged._",
inline=False
)
# A small hint footer
e.set_footer(text="Attach proof when possible to speed up moderation.")
return e
# --------------------- Views ----------------------
class ReportJumpView(discord.ui.View):
"""Simple link button to jump to the user's original ack message."""
def __init__(self, url: str):
super().__init__(timeout=None)
if url:
self.add_item(discord.ui.Button(label="Jump to message", style=discord.ButtonStyle.link, url=url))
# --------------------- Modals ----------------------
class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
def __init__(self, cog: "PirateReportCog"):
super().__init__()
self.cog = cog
self.character_name = discord.ui.TextInput(
label="In-game nickname",
placeholder="e.g., SandStalker",
max_length=64,
required=True
)
self.account_name = discord.ui.TextInput(
label="Account (Name#12345)",
placeholder="Must end with # and 5 digits",
max_length=64,
required=True
)
self.proof_url = discord.ui.TextInput(
label="Proof (Discord media URL — optional)",
placeholder="Direct Discord CDN link to image/video (highly encouraged, but optional)",
required=False,
max_length=300
)
self.add_item(self.character_name)
self.add_item(self.account_name)
self.add_item(self.proof_url)
async def on_submit(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
char = self.character_name.value.strip()
acct = self.account_name.value.strip()
proof_raw = (self.proof_url.value or "").strip()
if not _acct_ok(acct):
return await interaction.response.send_message(
"❌ Invalid account format. It must end with `#` and **five digits** (e.g. `SomeUser#12345`).",
ephemeral=True
)
proof_type = ""
proof_val = ""
if proof_raw:
pt, pv = _classify_discord_media(proof_raw)
if pt is None:
return await interaction.response.send_message(f"❌ Invalid proof link: {pv}", ephemeral=True)
proof_type, proof_val = pt, pv # valid
dm = self.cog.bot.data_manager
char_l = char.lower()
acct_l = acct.lower()
# Anti-spam: 60s per reporter
now = time.time()
recent = [r for r in dm.get('reports')
if r.get('submitter_id') == interaction.user.id and (now - r.get('ts', now)) < 60]
if recent:
return await interaction.response.send_message("⏱️ Please wait 60 seconds before submitting another report.", ephemeral=True)
# Already approved?
if any(p['account_name'].lower() == acct_l for p in dm.get('pirates')):
return await interaction.response.send_message("❌ This player is already in the pirate list.", ephemeral=True)
# Pending duplicate?
if any(r['character_name'].lower() == char_l and r['account_name'].lower() == acct_l for r in dm.get('reports')):
return await interaction.response.send_message("⏳ A report for this player is already pending moderation.", ephemeral=True)
# Acknowledge in channel so we can edit later when approved/rejected
try:
ack = await interaction.channel.send(f"{interaction.user.mention} thanks — your report was sent to moderators for review.")
except Exception as e:
print("[pirate_report] ack send failed:", repr(e))
ack = None
# Send to mod channel with ✅/❌, including inline media if present
mod_ch = interaction.guild.get_channel(self.cog.mod_channel)
if not mod_ch:
return await interaction.response.send_message("❌ Mod channel not configured.", ephemeral=True)
report_payload = {
'character_name': char,
'account_name': acct,
'submitter_id': interaction.user.id,
'proof_url': proof_val,
'proof_type': proof_type,
}
# Build jump button (if ack exists)
view = None
if ack:
try:
url = _jump_url(interaction.guild.id, interaction.channel.id, ack.id)
view = ReportJumpView(url)
except Exception:
view = None
try:
embed = _make_report_embed("🚩 Pirate Report", discord.Color.orange(), report_payload, include_status=False)
# Video must be in message content for inline player; images live in the embed
content = proof_val if proof_type == 'video' else None
mod_msg = await mod_ch.send(content=content, embed=embed, view=view)
await mod_msg.add_reaction(CHECK)
await mod_msg.add_reaction(CROSS)
except Exception as e:
print("[pirate_report] mod message failed:", repr(e))
return await interaction.response.send_message("❌ Failed to send to mod channel.", ephemeral=True)
dm.add('reports', {
'report_id': mod_msg.id,
'guild_id': interaction.guild.id,
'character_name': char,
'account_name': acct,
'submitter_id': interaction.user.id,
'origin_channel_id': interaction.channel.id if interaction.channel else 0,
'ack_message_id': ack.id if ack else 0,
'status': 'pending',
'ts': now,
'proof_url': proof_val,
'proof_type': proof_type,
})
await interaction.response.send_message("✅ Report submitted to moderators.", ephemeral=True)
class EditPirateModal(discord.ui.Modal, title="Edit Pirate Entry"):
def __init__(self, cog: "PirateReportCog"):
super().__init__()
self.cog = cog
self.old_account = discord.ui.TextInput(
label="Current account (Name#12345)",
placeholder="Exact current account in the list",
max_length=64,
required=True
)
self.new_character = discord.ui.TextInput(
label="New in-game nickname (optional)",
placeholder="Leave blank to keep existing",
max_length=64,
required=False
)
self.new_account = discord.ui.TextInput(
label="New account (optional, Name#12345)",
placeholder="Leave blank to keep existing",
max_length=64,
required=False
)
self.add_item(self.old_account)
self.add_item(self.new_character)
self.add_item(self.new_account)
async def on_submit(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not is_moderator_member(interaction.user, self.cog.bot):
return await interaction.response.send_message("This action is restricted to moderators.", ephemeral=True)
old_acct = self.old_account.value.strip()
new_char = (self.new_character.value or "").strip()
new_acct = (self.new_account.value or "").strip()
if not new_char and not new_acct:
return await interaction.response.send_message(
"❌ Provide **at least one** of: new nickname or new account.",
ephemeral=True
)
dm = self.cog.bot.data_manager
pirates = dm.get('pirates')
rec = next((p for p in pirates if p['account_name'].lower() == old_acct.lower()), None)
if not rec:
return await interaction.response.send_message("❌ Pirate not found.", ephemeral=True)
if new_acct:
if not _acct_ok(new_acct):
return await interaction.response.send_message("❌ Invalid new account format (Name#12345).", ephemeral=True)
if new_acct.lower() != old_acct.lower() and any(p['account_name'].lower() == new_acct.lower() for p in pirates):
return await interaction.response.send_message("❌ Another pirate already has that account.", ephemeral=True)
before = rec.copy()
dm.remove('pirates', lambda p: p['account_name'].lower() == old_acct.lower())
if new_char:
rec['character_name'] = new_char
if new_acct:
rec['account_name'] = new_acct
dm.add('pirates', rec)
await self.cog._modlog(
interaction.guild,
f"✏️ Edited pirate by {interaction.user.mention}: "
f"{'name ' + before['character_name'] + '' + rec['character_name'] if new_char else ''} "
f"{'/ account ' + before['account_name'] + '' + rec['account_name'] if new_acct else ''}".strip()
)
await interaction.response.send_message("✅ Pirate updated.", ephemeral=True)
await self.cog._refresh_pirates_list(interaction.guild)
class EncounterModal(discord.ui.Modal, title="Log Pirate Encounter"):
def __init__(self, cog: "PirateReportCog"):
super().__init__()
self.cog = cog
self.identifier = discord.ui.TextInput(
label="Pirate (name or account)",
placeholder="MuadDib or MuadDib#12345",
required=True,
max_length=64
)
self.group_size = discord.ui.TextInput(
label="Group size (including this pirate)",
placeholder="How many pirates were in the group?",
required=True
)
self.kills = discord.ui.TextInput(
label="Kills (integer ≥ 0; 0 = none/unknown)",
placeholder="How many did the pirate kill during the encounter",
required=True
)
self.destruction = discord.ui.TextInput(
label="Destructive? (yes/no)",
placeholder="Did they destroy a base/ornithopter? yes or no",
required=True
)
self.skill = discord.ui.TextInput(
label="Perceived Skill (05, 0 = unknown)",
placeholder="0..5",
required=True
)
self.add_item(self.identifier)
self.add_item(self.group_size)
self.add_item(self.kills)
self.add_item(self.destruction)
self.add_item(self.skill)
async def on_submit(self, interaction: discord.Interaction):
ident_raw = self.identifier.value.strip()
guild = interaction.guild
if guild is None:
return await interaction.response.send_message("This can only be used in a server.", ephemeral=True)
# Parse numeric fields
try:
group_size = int(self.group_size.value.strip()); assert group_size >= 1
except Exception:
return await interaction.response.send_message("❌ **Group size** must be an integer ≥ 1.", ephemeral=True)
try:
kills = int(self.kills.value.strip()); assert kills >= 0
except Exception:
return await interaction.response.send_message("❌ **Kills** must be an integer ≥ 0.", ephemeral=True)
try:
destruction = _parse_bool(self.destruction.value)
except ValueError:
return await interaction.response.send_message("❌ **Destructive** must be **yes** or **no**.", ephemeral=True)
try:
skill = int(self.skill.value.strip()); assert 0 <= skill <= 5
except Exception:
return await interaction.response.send_message("❌ **Skill** must be an integer between **0** and **5**.", ephemeral=True)
# Resolve to a single pirate record and canonical account identifier
pirate, error = self.cog.resolve_pirate(ident_raw)
if error:
return await interaction.response.send_message(error, ephemeral=True)
if not pirate:
return await interaction.response.send_message(
"❌ No such pirate registered. Ask a moderator to add them, or submit a **/report** for review.",
ephemeral=True
)
canonical_acct = pirate['account_name'] # <- always store account here
acct_l = canonical_acct.lower()
char_l = pirate['character_name'].lower()
dm = self.cog.bot.data_manager
now = time.time()
# Rate-limit by canonical account
recent = [
e for e in dm.get('encounters')
if e['reporter_id'] == interaction.user.id
and e.get('identifier','').lower() in (acct_l, char_l) # respect legacy
and (now - e['timestamp']) < 600
]
if recent:
return await interaction.response.send_message(
"⏱️ You can only report the same pirate once every **10 minutes**.",
ephemeral=True
)
# Store with canonical account as identifier
rec = {
'identifier': canonical_acct,
'reporter_id': interaction.user.id,
'timestamp': now,
'group_size': group_size,
'kills': kills,
'destruction': destruction,
'skill': skill
}
dm.add('encounters', rec)
# Recalculate threat (count both new account-based and legacy name-based entries)
encs = [e for e in dm.get('encounters') if e.get('identifier','').lower() in (acct_l, char_l)]
total = len(encs)
if total:
group_threshold = self.cog.group_threshold
kill_rate = sum(e['kills'] > 0 for e in encs) / total
group_rate = sum(e['group_size'] >= group_threshold for e in encs) / total
destroy_rate = sum(bool(e['destruction']) for e in encs) / total
skill_samples = [e['skill'] / 5 for e in encs if e['skill'] > 0]
skill_rate = (sum(skill_samples) / len(skill_samples)) if skill_samples else 0.0
w_k, w_d, w_g, w_s = self.cog.w_kill, self.cog.w_destruction, self.cog.w_group, self.cog.w_skill
weighted = (w_k * kill_rate) + (w_d * destroy_rate) + (w_g * group_rate) + (w_s * skill_rate)
threat_level = int(round(100 * min(1.0, max(0.0, weighted))))
# Update pirate record
pirates = dm.get('pirates')
def match(p): return p['account_name'].lower() == acct_l
rec0 = next((p for p in pirates if match(p)), None)
if rec0:
dm.remove('pirates', match)
rec0.update({'threat_level': threat_level, 'encounter_count': total})
dm.add('pirates', rec0)
await interaction.response.send_message(
f"✅ Encounter recorded for **{canonical_acct}**.",
ephemeral=True
)
await self.cog._refresh_pirates_list(interaction.guild)
# -------------- Cog: commands + listeners ---------------
class PirateReportCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
r = cfg(bot)
# Channels
self.mod_channel = r.int('mod_channel_id', 0)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
# Threat config
self.group_threshold = r.int('threat_group_threshold', 3)
def _f(key: str, default: float) -> float:
try:
return float(r.get(key, str(default)))
except Exception:
return default
self.w_kill = _f('threat_w_kill', 0.35)
self.w_destruction = _f('threat_w_destruction', 0.30)
self.w_group = _f('threat_w_group', 0.20)
self.w_skill = _f('threat_w_skill', 0.15)
async def _refresh_pirates_list(self, guild: discord.Guild):
plist = self.bot.get_cog('PiratesListCog')
if plist:
await plist.refresh_list(guild)
async def _modlog(self, guild: discord.Guild, content: str):
if not self.modlog_channel_id:
return
ch = guild.get_channel(self.modlog_channel_id)
if ch:
try:
await ch.send(content)
except Exception as e:
print("[pirate_report] modlog send failed:", repr(e))
self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content})
# --- Resolver: name/account -> pirate record (handles ambiguity) ---
def resolve_pirate(self, ident: str):
"""Return (pirate_record or None, error_message or None)."""
dm = self.bot.data_manager
pirates = dm.get('pirates')
s = ident.strip()
s_l = s.lower()
if '#' in s and _acct_ok(s):
rec = next((p for p in pirates if p['account_name'].lower() == s_l), None)
return (rec, None)
# resolve by character; must be unambiguous
matches = [p for p in pirates if p['character_name'].lower() == s_l]
if not matches:
return (None, None)
if len(matches) > 1:
return (None, "❌ That character name is **ambiguous**. Please use the account (e.g. `Name#12345`).")
return (matches[0], None)
# Remove pirate (mod-only)
@app_commands.command(name="remove_pirate", description="[MOD] Remove an approved pirate entry")
@app_commands.describe(account_name="Account name to remove")
async def remove_pirate(self, interaction: discord.Interaction, account_name: str):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
acct_lower = account_name.strip().lower()
dm = self.bot.data_manager
if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')):
return await interaction.response.send_message("Pirate not found.", ephemeral=True)
dm.remove('pirates', lambda p: p['account_name'].lower() == acct_lower)
await self._modlog(interaction.guild, f"🗑️ Removed pirate {account_name} by {interaction.user.mention}")
await self._refresh_pirates_list(interaction.guild)
await interaction.response.send_message("Removed.", ephemeral=True)
# Modal launchers
@app_commands.command(name="report", description="Submit a pirate report for moderator review (opens a form)")
async def report(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
await interaction.response.send_modal(ReportModal(self))
@app_commands.command(name="edit_pirate", description="[MOD] Edit a pirate entry (opens a form)")
async def edit_pirate(self, interaction: discord.Interaction):
if not await require_mod_interaction(interaction):
return
await interaction.response.send_modal(EditPirateModal(self))
@app_commands.command(name="encounter", description="Log a pirate encounter (opens a form)")
async def encounter(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
await interaction.response.send_modal(EncounterModal(self))
# ---- Migration: convert encounter identifiers to accounts (mod-only) ----
@app_commands.command(name="encounters_migrate_ids", description="[MOD] Migrate encounter identifiers to account names")
async def encounters_migrate_ids(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
await interaction.response.defer(ephemeral=True)
dm = self.bot.data_manager
pirates = dm.get('pirates')
by_char = {}
for p in pirates:
by_char.setdefault(p['character_name'].lower(), []).append(p)
by_acct = {p['account_name'].lower(): p for p in pirates}
changed = ambiguous = missing = already = 0
for e in dm.get('encounters'):
ident = e.get('identifier', '')
if not ident:
continue
if '#' in ident:
already += 1
continue
key = ident.lower()
matches = by_char.get(key, [])
if not matches:
missing += 1
continue
if len(matches) > 1:
ambiguous += 1
continue
acct = matches[0]['account_name']
def pred(x, ts=e['timestamp'], rid=e['reporter_id'], ident_old=ident):
return x.get('timestamp') == ts and x.get('reporter_id') == rid and x.get('identifier') == ident_old
def upd(x, acct_new=acct):
x['identifier'] = acct_new
return x
ok = dm.update('encounters', pred, upd)
if ok:
changed += 1
await interaction.followup.send(
"Migration complete.\n"
f"- Updated to accounts: **{changed}**\n"
f"- Already accounts: **{already}**\n"
f"- Ambiguous character names (skipped): **{ambiguous}**\n"
f"- Not found in pirates list (skipped): **{missing}**",
ephemeral=True
)
await self._refresh_pirates_list(interaction.guild)
# Moderator reaction handling (atomic claim)
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
if (
payload.channel_id != self.mod_channel
or str(payload.emoji) not in (CHECK, CROSS)
or payload.user_id == self.bot.user.id
):
return
channel = self.bot.get_channel(payload.channel_id)
if not channel or not channel.guild:
return
if not is_moderator_userid(channel.guild, payload.user_id, self.bot):
return
try:
msg = await channel.fetch_message(payload.message_id)
except Exception as e:
print("[pirate_report] fetch mod msg failed:", repr(e))
return
dm = self.bot.data_manager
# Atomically claim this report
claimed = dm.update(
'reports',
lambda r: r.get('report_id') == msg.id and r.get('status') == 'pending',
lambda r: (r.update({'status': 'deciding'}), r)[1]
)
if not claimed:
return
report = next((r for r in dm.get('reports') if r.get('report_id') == msg.id), None)
if not report:
return
approved = (str(payload.emoji) == CHECK)
guild = channel.guild
stamp = _now_utc_str()
# Build new embed + content (keep proof visible if present)
status_text = f"{'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}"
report_view = {
'character_name': report['character_name'],
'account_name': report['account_name'],
'submitter_id': report['submitter_id'],
'proof_url': report.get('proof_url', ''),
'proof_type': report.get('proof_type', ''),
'status_line': status_text,
}
color = discord.Color.green() if approved else discord.Color.red()
title = "✅ Pirate Report — Approved" if approved else "❌ Pirate Report — Rejected"
new_embed = _make_report_embed(title, color, report_view, include_status=True)
# For videos, ensure the URL stays in message content so the inline player remains visible.
new_content = report_view['proof_url'] if report_view.get('proof_type') == 'video' else None
# Always (re)attach jump button if we have the ack info
view = None
try:
if report.get('origin_channel_id') and report.get('ack_message_id'):
url = _jump_url(guild.id, report['origin_channel_id'], report['ack_message_id'])
view = ReportJumpView(url)
except Exception:
view = None
try:
await msg.clear_reactions()
except Exception as e:
print("[pirate_report] clear reactions failed:", repr(e))
try:
await msg.edit(content=new_content, embed=new_embed, view=view)
except Exception as e:
print("[pirate_report] edit mod msg failed:", repr(e))
if approved:
acct_lower = report['account_name'].lower()
if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')):
dm.add('pirates', {
'character_name': report['character_name'],
'account_name': report['account_name'],
'submitter': str(report['submitter_id']),
'threat_level': 0,
'encounter_count': 0,
'added_ts': time.time()
})
await self._refresh_pirates_list(guild)
await self._modlog(guild, f"{'✅ Approved' if approved else '❌ Rejected'} report for {report['character_name']} ({report['account_name']}) by <@{payload.user_id}>.")
try:
if report.get('origin_channel_id') and report.get('ack_message_id'):
ch = guild.get_channel(report['origin_channel_id'])
if ch:
ack_msg = await ch.fetch_message(report['ack_message_id'])
await ack_msg.edit(content=f"{('✅ Approved' if approved else '❌ Rejected')} pirate report of {report['character_name']} ({report['account_name']}) by <@{payload.user_id}> on {stamp}.")
except Exception as e:
print("[pirate_report] edit ack msg failed:", repr(e))
dm.remove('reports', lambda r: r.get('report_id') == msg.id)
async def setup(bot):
await bot.add_cog(PirateReportCog(bot))

View File

View File

@ -0,0 +1,228 @@
# modules/pirates_list/pirates_list.py
import asyncio
import discord
from discord.ext import commands
from discord import app_commands
from mod_perms import require_mod_ctx, require_mod_interaction
from modules.common.settings import cfg as _cfg
class PiratesListCog(commands.Cog):
"""
Maintains a compact, alphabetized pirates list in the configured channel.
Entry format:
- **{Character}** (*{Account}*) [{Threat}%]
- In group: {never/rarely/...}. Destructive: {never/rarely/...}. Encounters: N. Last: <t:UNIX:R>
Posts are chunked to stay <2000 chars and previous posts are deleted on refresh.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
c = _cfg(bot)
self.list_channel_id = c.int("pirates_list_channel_id")
self.group_threshold = c.int("threat_group_threshold", 3)
self.min_samples = c.int("threat_min_samples_for_stats", 3)
# serialize refreshes per guild
self._locks: dict[int, asyncio.Lock] = {}
# never ping on posted content
self._no_mentions = discord.AllowedMentions.none()
# ----------------- utils -----------------
def _lock_for(self, guild_id: int) -> asyncio.Lock:
self._locks.setdefault(guild_id, asyncio.Lock())
return self._locks[guild_id]
@staticmethod
def _esc(s: str) -> str:
"""Escape markdown and neuter @mentions in user-provided strings."""
safe = discord.utils.escape_markdown(str(s))
return safe.replace("@", "@\u200b")
def _encounters_for(self, pirate: dict):
"""Return encounters matching either char name or account (case-insensitive), defensively."""
acct_l = str(pirate.get("account_name", "")).lower()
char_l = str(pirate.get("character_name", "")).lower()
out = []
for e in self.bot.data_manager.get("encounters"):
try:
ident = str(e.get("identifier", "")).lower()
if ident in (acct_l, char_l):
out.append(e)
except Exception:
continue
return out
def _bucket(self, rate: float, samples: int) -> str:
if samples < self.min_samples:
return "unknown"
if rate <= 0:
return "never"
if rate <= 0.25:
return "rarely"
if rate <= 0.60:
return "sometimes"
if rate <= 0.85:
return "often"
return "always"
def _format_entry(self, p: dict) -> str:
encs = self._encounters_for(p)
total = len(encs)
def _safe_int(v, default=0):
try:
return int(v)
except Exception:
return default
group_rate = (
sum(1 for e in encs if _safe_int(e.get("group_size"), 0) >= self.group_threshold) / total
if total else 0.0
)
destroy_rate = (
sum(1 for e in encs if bool(e.get("destruction"))) / total
if total else 0.0
)
groups_str = self._bucket(group_rate, total)
destr_str = self._bucket(destroy_rate, total)
last_ts = None
if total:
try:
last_ts = max(int(float(e.get("timestamp", 0))) for e in encs)
except Exception:
last_ts = None
char = self._esc(p.get("character_name", "Unknown"))
acct = self._esc(p.get("account_name", "Unknown#00000"))
threat = int(p.get("threat_level", 0))
last_str = f"<t:{last_ts}:R>" if last_ts else ""
line1 = f"- **{char}** (*{acct}*) [{threat}%]"
line2 = f" - In group: {groups_str}. Destructive: {destr_str}. Encounters: {total}. Last: {last_str}"
return f"{line1}\n{line2}"
async def refresh_list(self, guild: discord.Guild):
"""Edit list messages in place; only send extra messages when we need more chunks (new pirates)."""
async with self._lock_for(guild.id):
channel = guild.get_channel(self.list_channel_id)
if not channel:
print("[pirates_list] list channel not found:", self.list_channel_id)
return
dm = self.bot.data_manager
allow = self._no_mentions
# ---- load & prune existing posts for this guild/channel ----
records = [r for r in dm.get("pirates_list_posts")
if r.get("guild_id") == guild.id and r.get("channel_id") == self.list_channel_id]
msgs, kept_records = [], []
for r in records:
try:
m = await channel.fetch_message(int(r["message_id"]))
msgs.append(m)
kept_records.append(r)
except Exception:
dm.remove("pirates_list_posts", lambda x, mid=r["message_id"]: x.get("message_id") == mid)
# ---- build fresh, sorted contents ----
pirates = sorted(
dm.get("pirates"),
key=lambda x: (
str(x.get("character_name", "")).lower(),
str(x.get("account_name", "")).lower()
)
)
# Empty state
if not pirates:
placeholder = "_No verified pirates yet._"
if msgs:
if msgs[0].content != placeholder:
try:
await msgs[0].edit(content=placeholder, allowed_mentions=allow)
except Exception as e:
print("[pirates_list] edit placeholder failed:", repr(e))
for extra in msgs[1:]:
try:
await extra.delete()
except Exception:
pass
dm.remove("pirates_list_posts", lambda r, mid=extra.id: r.get("message_id") == mid)
else:
try:
m = await channel.send(placeholder, allowed_mentions=allow)
dm.add("pirates_list_posts", {
"guild_id": guild.id,
"channel_id": self.list_channel_id,
"message_id": m.id
})
except Exception as e:
print("[pirates_list] send placeholder failed:", repr(e))
return
# Chunk into <2000 char blocks (~1900 for margin)
chunks, current = [], ""
for p in pirates:
entry = self._format_entry(p)
if len(current) + len(entry) + 1 > 1900:
chunks.append(current.rstrip())
current = entry + "\n"
else:
current += entry + "\n"
if current.strip():
chunks.append(current.rstrip())
# ---- edit existing messages with new content ----
common = min(len(msgs), len(chunks))
for i in range(common):
if msgs[i].content != chunks[i]:
try:
await msgs[i].edit(content=chunks[i], allowed_mentions=allow)
except Exception as e:
print("[pirates_list] edit block failed:", repr(e))
# ---- send additional messages if needed ----
if len(chunks) > len(msgs):
for i in range(len(msgs), len(chunks)):
try:
m = await channel.send(chunks[i], allowed_mentions=allow)
dm.add("pirates_list_posts", {
"guild_id": guild.id,
"channel_id": self.list_channel_id,
"message_id": m.id
})
except Exception as e:
print("[pirates_list] send block failed:", repr(e))
# ---- delete extras if fewer chunks now ----
elif len(chunks) < len(msgs):
extras = msgs[len(chunks):]
for m in extras:
try:
await m.delete()
except Exception:
pass
dm.remove("pirates_list_posts", lambda r, mid=m.id: r.get("message_id") == mid)
# Manual refresh command (hybrid: works as /pirates_list_refresh and !pirates_list_refresh)
@app_commands.command(name="pirates_list_refresh", description="[MOD] Rebuild the compact pirates list")
@app_commands.checks.cooldown(1, 10) # guild-scope anti-spam analogue
async def pirates_list_refresh(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
await interaction.response.defer(ephemeral=True)
await self.refresh_list(interaction.guild)
await interaction.followup.send("Pirates list refreshed.", ephemeral=True)
async def setup(bot: commands.Bot):
await bot.add_cog(PiratesListCog(bot))

View File

150
modules/power/power.py Normal file
View 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")

View File

View File

@ -0,0 +1,941 @@
# modules/reaction_role/reaction_role.py
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Iterable, Tuple
import time
import discord
from discord.ext import commands
from modules.common.emoji_accept import is_accept
from modules.common.settings import cfg # dynamic settings helper
CHECKMARK = '' # kept for consistency with other flows
# ------------------ small helpers ------------------
def _fmt_dt(ts: float) -> str:
return discord.utils.format_dt(datetime.fromtimestamp(ts, tz=timezone.utc), style="f")
def _as_list(x: Optional[Iterable]) -> list:
return list(x) if isinstance(x, Iterable) else []
# ------------------ Fedaykin approval buttons ------------------
class _FedaykinApprovalView(discord.ui.View):
"""Approval buttons for a single Fedaykin request (persistent)."""
def __init__(self, cog: "ReactionRoleCog", req: Dict[str, Any], *, timeout: Optional[float] = None):
super().__init__(timeout=timeout)
self.cog = cog
self.req = req # dict persisted via data_manager
async def interaction_check(self, interaction: discord.Interaction) -> bool:
# Only Field Mod or Admin may act — and only if a Head exists
member = interaction.user
if not isinstance(member, discord.Member):
await interaction.response.send_message("Server context required.", ephemeral=True)
return False
if self.cog._fedaykin_headless or not (interaction.guild and self.cog._has_fedaykin_head(interaction.guild)):
await interaction.response.send_message(
"No **Fedaykin Head** is currently appointed. This request remains pending until one is appointed.",
ephemeral=True
)
return False
allow_roles = {self.cog.field_mod_role_id, self.cog.admin_role_id}
if not any(r.id in allow_roles for r in getattr(member, "roles", [])):
await interaction.response.send_message("You are not allowed to decide this request.", ephemeral=True)
return False
return True
async def _finish(self, interaction: discord.Interaction, status: str):
await self.cog._fedaykin_decide(
guild=interaction.guild,
actor=interaction.user if isinstance(interaction.user, discord.Member) else None,
req=self.req,
status=status,
edit_view=True,
)
try:
await interaction.response.send_message(f"{status.capitalize()}.", ephemeral=True)
except Exception:
pass
@discord.ui.button(label="Approve", style=discord.ButtonStyle.success, custom_id="fdk.approve")
async def _approve(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._finish(interaction, "approved")
@discord.ui.button(label="Reject", style=discord.ButtonStyle.danger, custom_id="fdk.reject")
async def _reject(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._finish(interaction, "rejected")
# ------------------ Main cog ------------------
class ReactionRoleCog(commands.Cog):
"""
Original: Records agreements and manages Full Access.
Added: Crew reaction roles (Harvester/Escort toggles) + Fedaykin approval flow via buttons.
- No debug slash commands.
- Debounced nickname review to avoid duplicates when users add multiple accept emojis.
- Fedaykin role is removed when the user unreacts the Fedaykin emoji.
- Settings are reloaded dynamically on each event (hot-apply without restart).
- NEW: Only users with Full Access may claim/request crew roles.
"""
def __init__(self, bot):
self.bot = bot
# Snapshot (will be refreshed dynamically on each event)
self._settings = {}
self._hub_channel_id: Optional[int] = None # cache for crew hub channel
# Debounce / locks to prevent duplicate nickname review on bursty reactions
self._nick_debounce: Dict[tuple[int, int], float] = {} # (guild_id, user_id) -> last_ts
self._nick_lock: set[tuple[int, int]] = set() # in-flight review creations
# Fedaykin head state (global)
self._fedaykin_headless: bool = False
# Apply initial snapshot and schedule restore
self._refresh_settings(force=True)
self.bot.loop.create_task(self._boot_restore())
# ------------------ settings ------------------
def _refresh_settings(self, *, force: bool = False):
"""Hot-read settings on demand; cheap and avoids restart requirement."""
r = cfg(self.bot)
s = {
# Message IDs
"rules_msg_id": r.int('rules_message_id', 0),
"engage_msg_id": r.int('engagement_message_id', 0),
"nick_msg_id": r.int('nickname_message_id', 0),
"crew_msg_id": r.int('crew_roles_message_id', 0),
# Role IDs
"rules_role": r.int('rules_role_id', 0),
"engage_role": r.int('engagement_role_id', 0),
"full_access_role": r.int('full_access_role_id', 0),
"role_harvest_id": r.int('role_harvest_crew_id', 0),
"role_escort_id": r.int('role_escort_crew_id', 0),
"role_fedaykin_id": r.int('role_fedaykin_id', 0),
# Approver roles
"field_mod_role_id": r.int('field_mod_role_id', 0),
"admin_role_id": r.int('admin_role_id', 0),
# Emojis
"emoji_harvest_id": r.int('emoji_harvester_crew', 0),
"emoji_escort_id": r.int('emoji_escort_crew', 0),
"emoji_fedaykin_id": r.int('emoji_fedaykin', 0),
# Channels
"report_channel_id": r.int('report_channel_id', 0),
"mod_channel_id": r.int('mod_channel_id', 0),
"modlog_channel_id": r.int('modlog_channel_id', 0),
}
self._settings = s
# handy properties (avoid tons of self._settings["x"] everywhere)
@property
def rules_msg_id(self) -> int: return self._settings.get("rules_msg_id", 0)
@property
def engage_msg_id(self) -> int: return self._settings.get("engage_msg_id", 0)
@property
def nick_msg_id(self) -> int: return self._settings.get("nick_msg_id", 0)
@property
def crew_msg_id(self) -> int: return self._settings.get("crew_msg_id", 0)
@property
def rules_role(self) -> int: return self._settings.get("rules_role", 0)
@property
def engage_role(self) -> int: return self._settings.get("engage_role", 0)
@property
def full_access_role(self) -> int: return self._settings.get("full_access_role", 0)
@property
def role_harvest_id(self) -> int: return self._settings.get("role_harvest_id", 0)
@property
def role_escort_id(self) -> int: return self._settings.get("role_escort_id", 0)
@property
def role_fedaykin_id(self) -> int: return self._settings.get("role_fedaykin_id", 0)
@property
def field_mod_role_id(self) -> int: return self._settings.get("field_mod_role_id", 0)
@property
def admin_role_id(self) -> int: return self._settings.get("admin_role_id", 0)
@property
def emoji_harvest_id(self) -> int: return self._settings.get("emoji_harvest_id", 0)
@property
def emoji_escort_id(self) -> int: return self._settings.get("emoji_escort_id", 0)
@property
def emoji_fedaykin_id(self) -> int: return self._settings.get("emoji_fedaykin_id", 0)
@property
def report_channel_id(self) -> int: return self._settings.get("report_channel_id", 0)
@property
def mod_channel_id(self) -> int: return self._settings.get("mod_channel_id", 0)
@property
def modlog_channel_id(self) -> int: return self._settings.get("modlog_channel_id", 0)
# ------------------ boot & persistence ------------------
async def _boot_restore(self):
await self.bot.wait_until_ready()
self._refresh_settings()
dm = self.bot.data_manager
# Ensure list key exists to be safe
if not isinstance(dm.get('fedaykin_requests'), list):
try:
dm.add('fedaykin_requests', {"_init": True})
dm.remove('fedaykin_requests', lambda x: isinstance(x, dict) and x.get("_init") is True)
except Exception:
pass
# Re-register views for any pending requests
try:
for req in _as_list(dm.get('fedaykin_requests')):
if req.get("status") == "pending" and req.get("review_message_id") and req.get("review_channel_id"):
self.bot.add_view(_FedaykinApprovalView(self, req), message_id=int(req["review_message_id"]))
except Exception:
pass
# Evaluate head state at boot and act accordingly
try:
hg = cfg(self.bot).int('home_guild_id', 0)
guild = self.bot.get_guild(hg) if hg else (self.bot.guilds[0] if self.bot.guilds else None)
await self._maybe_transition_head_state(guild)
except Exception:
pass
try:
if guild:
await self._repair_orphaned_pending_cards(guild)
except Exception:
pass
async def _save_fedaykin_request(self, req: Dict[str, Any]):
"""Upsert by (guild_id, user_id)."""
dm = self.bot.data_manager
gid, uid = int(req["guild_id"]), int(req["user_id"])
try:
for _ in list(_as_list(dm.get('fedaykin_requests'))):
dm.remove('fedaykin_requests', lambda x, gid=gid, uid=uid:
int(x.get("guild_id", 0)) == gid and int(x.get("user_id", 0)) == uid)
dm.add('fedaykin_requests', dict(req))
except Exception:
pass
# ------------------ shared helpers ------------------
async def _get_member(self, guild: discord.Guild, user_id: int):
if not guild:
return None
m = guild.get_member(user_id)
if m is None:
try:
m = await guild.fetch_member(user_id)
except Exception:
return None
return m
async def _user_has_any_accept(self, guild: discord.Guild, channel_id: int, message_id: int, user_id: int) -> bool:
"""Return True if the user still has at least one 'accept' reaction on the message."""
try:
ch = guild.get_channel(channel_id)
if not ch:
return False
msg = await ch.fetch_message(message_id)
for rxn in msg.reactions:
if is_accept(rxn.emoji):
async for u in rxn.users(limit=None):
if u.id == user_id:
return True
return False
except Exception:
return False
def _has_full_initiated(self, member: discord.Member) -> bool:
"""User must have Full Access role to claim/request crew roles."""
if not member or not isinstance(member.guild, discord.Guild):
return False
role = member.guild.get_role(self.full_access_role) if self.full_access_role else None
return bool(role and role in member.roles)
async def _remove_reaction_silent(self, guild: discord.Guild, channel_id: int, message_id: int,
emoji: discord.PartialEmoji | discord.Emoji | str, member: discord.Member):
"""Best-effort: remove a reaction without messaging the user."""
try:
ch = guild.get_channel(channel_id)
if not isinstance(ch, (discord.TextChannel, discord.Thread)):
return
msg = await ch.fetch_message(message_id)
await msg.remove_reaction(emoji, member)
except Exception:
pass
async def maybe_apply_full_access(self, member: discord.Member):
"""Grant when Rules+RoE+Nickname *claimed*; revoke when any missing."""
guild = member.guild
role = guild.get_role(self.full_access_role) if self.full_access_role else None
if not role:
return
dm = self.bot.data_manager
has_rules = member.id in _as_list(dm.get('agreed_rules'))
has_engage = member.id in _as_list(dm.get('agreed_engagement'))
has_nick_claim = member.id in _as_list(dm.get('agreed_nickname'))
has_all = has_rules and has_engage and has_nick_claim
try:
if has_all and role not in member.roles:
await member.add_roles(role, reason="All agreements completed (nickname may be pending)")
elif not has_all and role in member.roles:
await member.remove_roles(role, reason="Agreements incomplete or nickname unclaimed")
except discord.Forbidden:
pass
except Exception:
pass
# Best-effort: refresh user card
cards = self.bot.get_cog('UserCardsCog')
if cards:
try:
await cards.refresh_card(member)
except Exception:
pass
async def _find_hub_message(self, guild: discord.Guild) -> Tuple[Optional[discord.Message], Optional[int]]:
"""Return (message, channel_id) for the hub message, scanning text channels if needed."""
if not self.crew_msg_id:
return None, None
# Try cached channel
if self._hub_channel_id:
ch = guild.get_channel(self._hub_channel_id)
if isinstance(ch, discord.TextChannel):
try:
msg = await ch.fetch_message(self.crew_msg_id)
return msg, ch.id
except Exception:
pass
# Scan
for ch in guild.text_channels:
try:
msg = await ch.fetch_message(self.crew_msg_id)
self._hub_channel_id = ch.id
return msg, ch.id
except (discord.NotFound, discord.Forbidden):
continue
except Exception:
continue
return None, None
async def _edit_review_message_footer(self, req: Dict[str, Any], note: str, *, clear_view: bool = False):
try:
ch = self.bot.get_channel(int(req["review_channel_id"]))
if isinstance(ch, (discord.TextChannel, discord.Thread)):
msg = await ch.fetch_message(int(req["review_message_id"]))
emb = msg.embeds[0] if msg.embeds else discord.Embed(title="Fedaykin Request")
if emb.footer and emb.footer.text:
emb.set_footer(text=emb.footer.text + "\n" + note)
else:
emb.set_footer(text=note)
if clear_view:
await msg.edit(embed=emb, view=None)
else:
await msg.edit(embed=emb) # leave existing components untouched
except Exception:
pass
async def _modlog_decision(self, guild: discord.Guild, status: str, target: discord.Member, actor: discord.Member, req: Dict[str, Any]):
"""Send an approval/rejection/revoke line to modlog channel."""
chan = self.bot.get_channel(self.modlog_channel_id)
if not isinstance(chan, (discord.TextChannel, discord.Thread)):
return
color_map = {
"approved": discord.Color.green(),
"rejected": discord.Color.red(),
"withdrawn": discord.Color.orange(),
"revoked": discord.Color.orange()
}
colour = color_map.get(status, discord.Color.blurple())
jump = f"https://discord.com/channels/{guild.id}/{req.get('review_channel_id')}/{req.get('review_message_id')}" \
if req.get('review_channel_id') and req.get('review_message_id') else ""
created_ts = float(req.get("created_ts", datetime.now(timezone.utc).timestamp()))
decided_ts = float(req.get("decision_ts", datetime.now(timezone.utc).timestamp()))
emb = discord.Embed(
title=f"Fedaykin {status.capitalize()}",
description=(f"[View review card]({jump})" if jump else "No card link available."),
color=colour,
timestamp=datetime.fromtimestamp(decided_ts, tz=timezone.utc),
)
emb.add_field(name="User", value=f"{target.mention} (`{target.id}`)", inline=True)
emb.add_field(name="By", value=f"{actor.mention} (`{actor.id}`)", inline=True)
emb.add_field(name="Requested", value=_fmt_dt(created_ts), inline=True)
emb.add_field(name="Decision", value=_fmt_dt(decided_ts), inline=True)
try:
await chan.send(embed=emb)
except Exception:
pass
async def _fedaykin_decide(self, guild: Optional[discord.Guild], actor: Optional[discord.Member], req: Dict[str, Any], status: str, *, edit_view: bool = False):
"""Common decision path for approvals via buttons."""
if guild is None or actor is None:
return
# Idempotency
if req.get("status") != "pending" and status in ("approved", "rejected"):
return
target = await self._get_member(guild, int(req["user_id"]))
if target is None:
return
role = guild.get_role(self.role_fedaykin_id)
if status == "approved":
if role is None:
await self._edit_review_message_footer(
req, f"**Decision attempt:** Approve by {actor.mention} failed — Fedaykin role not configured."
)
return
try:
await target.add_roles(role, reason=f"Fedaykin approved by {actor} ({actor.id})")
except Exception:
pass
elif status == "rejected":
if role and role in target.roles:
try:
await target.remove_roles(role, reason=f"Fedaykin rejected by {actor} ({actor.id})")
except Exception:
pass
elif status in ("withdrawn", "revoked"):
# Both withdrawn (user cancels before approval) and revoked (user unreacts after approval)
if role and role in target.roles:
try:
await target.remove_roles(role, reason=f"Fedaykin {status} by user")
except Exception:
pass
if status in ("approved", "rejected"):
req["status"] = status
req["approver_id"] = int(actor.id)
else:
req["status"] = status # withdrawn / revoked don't set approver
req["decision_ts"] = datetime.now(timezone.utc).timestamp()
await self._save_fedaykin_request(req)
note = f"**Decision:** {status.capitalize()} by {actor.mention if actor else 'system'} at {discord.utils.format_dt(datetime.now(timezone.utc), style='f')}"
await self._edit_review_message_footer(req, note, clear_view=edit_view)
# modlog
try:
await self._modlog_decision(guild, status, target, actor or guild.me, req)
except Exception:
pass
async def _post_fedaykin_card(self, guild: discord.Guild, member: discord.Member, hub_id: int) -> bool:
"""Post the Fedaykin approval card; return True if posted somewhere. While headless, queue pending only."""
# Require Full Access to even request Fedaykin
if not self._has_full_initiated(member):
return False
# If headless or effectively headless (no Head members): queue silently
if self._fedaykin_headless or not self._has_fedaykin_head(guild):
await self._queue_pending(guild, member, reason="headless_runtime")
return False
created_ts = datetime.now(timezone.utc).timestamp()
emb = discord.Embed(
title="Fedaykin Request",
description=f"{member.mention} requested **Fedaykin** via reaction.",
color=discord.Color.orange(),
timestamp=datetime.fromtimestamp(created_ts, tz=timezone.utc),
)
emb.add_field(name="User", value=f"{member} (`{member.id}`)", inline=True)
emb.add_field(name="Requested", value=_fmt_dt(created_ts), inline=True)
emb.set_footer(text=f"Hub message ID: {hub_id}")
req = {
"guild_id": guild.id,
"user_id": member.id,
"status": "pending",
"created_ts": created_ts,
"review_message_id": 0,
"review_channel_id": 0,
}
view = _FedaykinApprovalView(self, req, timeout=None)
# Primary: report_channel_id; fallback: mod_channel_id
targets = []
chan = self.bot.get_channel(self.report_channel_id)
if isinstance(chan, (discord.TextChannel, discord.Thread)):
targets.append(chan)
mchan = self.bot.get_channel(self.mod_channel_id)
if isinstance(mchan, (discord.TextChannel, discord.Thread)):
targets.append(mchan)
for ch in targets:
try:
msg = await ch.send(embed=emb, view=view)
req["review_message_id"] = int(msg.id)
req["review_channel_id"] = int(msg.channel.id)
await self._save_fedaykin_request(req)
try:
self.bot.add_view(_FedaykinApprovalView(self, req), message_id=msg.id)
except Exception:
pass
return True
except Exception:
continue
return False
# ---------- Fedaykin Head detection & transitions ----------
def _has_fedaykin_head(self, guild: discord.Guild) -> bool:
"""True if any member currently has the Field Mod (Fedaykin Head) role."""
if not guild or not self.field_mod_role_id:
return False
role = guild.get_role(self.field_mod_role_id)
return bool(role and role.members)
async def _modlog_head_summary(self, guild: discord.Guild, *, head_present: bool,
revoked: int = 0, queued: int = 0, posted: int = 0):
"""Brief summary in modlog on headless/head-found transitions."""
chan = self.bot.get_channel(self.modlog_channel_id)
if not isinstance(chan, (discord.TextChannel, discord.Thread)):
return
colour = discord.Color.green() if head_present else discord.Color.orange()
title = "Fedaykin Head present — queued reviews sent" if head_present else "No Fedaykin Head — roles revoked & queued"
desc = (
f"**Posted reviews:** {posted}" if head_present else
f"**Revoked roles:** {revoked}\n**Queued reviews:** {queued}"
)
try:
emb = discord.Embed(title=title, description=desc, color=colour,
timestamp=datetime.now(timezone.utc))
await chan.send(embed=emb)
except Exception:
pass
async def _queue_pending(self, guild: discord.Guild, member: discord.Member, *, reason: str = "headless"):
"""Upsert a silent pending request (no card)."""
dm = self.bot.data_manager
created_ts = datetime.now(timezone.utc).timestamp()
# Preserve an existing headless pending if one already exists (dont duplicate)
existing = next((x for x in _as_list(dm.get('fedaykin_requests'))
if x.get("guild_id") == guild.id and x.get("user_id") == member.id
and x.get("status") == "pending" and int(x.get("review_message_id", 0)) == 0), None)
req = {
"guild_id": guild.id,
"user_id": member.id,
"status": "pending",
"created_ts": existing.get("created_ts", created_ts) if existing else created_ts,
"review_message_id": 0,
"review_channel_id": 0,
"reason": reason,
}
await self._save_fedaykin_request(req)
async def _headless_revoke_and_queue(self, guild: discord.Guild) -> tuple[int, int]:
"""When headless: remove Fedaykin role from all members and queue pending reviews."""
revoked = queued = 0
if not guild or not self.role_fedaykin_id:
return revoked, queued
role = guild.get_role(self.role_fedaykin_id)
if not role:
return revoked, queued
for m in list(role.members):
if m.bot:
continue
try:
await m.remove_roles(role, reason="Fedaykin headless role temporarily revoked")
revoked += 1
except Exception:
pass
try:
await self._queue_pending(guild, m, reason="headless_boot")
queued += 1
except Exception:
pass
return revoked, queued
async def _flush_pending_to_cards(self, guild: discord.Guild) -> int:
"""When a head exists: send cards for any queued pendings (review_message_id == 0)."""
dm = self.bot.data_manager
pending = [x for x in _as_list(dm.get('fedaykin_requests'))
if x.get("guild_id") == guild.id and x.get("status") == "pending"
and int(x.get("review_message_id", 0)) == 0]
posted = 0
for req in pending:
member = await self._get_member(guild, int(req.get("user_id", 0)))
if not member:
continue
# This will upsert the request with live message/channel IDs
ok = await self._post_fedaykin_card(guild, member, self.crew_msg_id or 0)
if ok:
posted += 1
return posted
async def _maybe_transition_head_state(self, guild: Optional[discord.Guild]):
"""Recompute head state; if it changes, perform the required mass action + log."""
if guild is None:
return
# Require a configured, resolvable Field Mod role to take global actions
if not self.field_mod_role_id:
return
head_role = guild.get_role(self.field_mod_role_id)
if head_role is None:
# Misconfigured: do not mass-revoke; other paths will still queue/block appropriately
return
has_head_now = bool(head_role.members)
if has_head_now and self._fedaykin_headless:
# Head appeared — flush queued to cards
self._fedaykin_headless = False
posted = await self._flush_pending_to_cards(guild)
await self._repair_orphaned_pending_cards(guild)
await self._modlog_head_summary(guild, head_present=True, posted=posted)
elif not has_head_now and not self._fedaykin_headless:
# Became headless — revoke all fedaykins and queue pendings
self._fedaykin_headless = True
revoked, queued = await self._headless_revoke_and_queue(guild)
await self._modlog_head_summary(guild, head_present=False, revoked=revoked, queued=queued)
# inside ReactionRoleCog class
async def _repair_orphaned_pending_cards(self, guild: discord.Guild) -> int:
"""Detect pending requests whose review message was deleted; reset them so they can be re-posted."""
dm = self.bot.data_manager
repaired = 0
for req in list(_as_list(dm.get('fedaykin_requests'))):
if int(req.get("guild_id", 0)) != guild.id or req.get("status") != "pending":
continue
mid = int(req.get("review_message_id", 0) or 0)
cid = int(req.get("review_channel_id", 0) or 0)
if not mid or not cid:
continue # already queued (no card)
ch = self.bot.get_channel(cid)
exists = False
if isinstance(ch, (discord.TextChannel, discord.Thread)):
try:
await ch.fetch_message(mid)
exists = True
except (discord.NotFound, discord.Forbidden):
exists = False
except Exception:
exists = False
if not exists:
# reset to "queued" so normal flow can re-post
req["review_message_id"] = 0
req["review_channel_id"] = 0
await self._save_fedaykin_request(req)
repaired += 1
# If we repaired and a head exists, immediately flush to new cards
if repaired and not self._fedaykin_headless and self._has_fedaykin_head(guild):
await self._flush_pending_to_cards(guild)
return repaired
# ------------------ listeners ------------------
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member):
"""Detect when someone gains/loses the Fedaykin Head role and transition state if needed."""
try:
hg = cfg(self.bot).int('home_guild_id', 0)
if after.guild.id != (hg or after.guild.id):
return
except Exception:
pass
# If the role IDs arent configured yet, just ignore
if not self.field_mod_role_id:
return
had = any(r.id == self.field_mod_role_id for r in getattr(before, "roles", []))
has = any(r.id == self.field_mod_role_id for r in getattr(after, "roles", []))
if had == has:
return # no change on this member
# Re-evaluate overall head presence and transition if the global state changes
await self._maybe_transition_head_state(after.guild)
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
# hot-reload settings
self._refresh_settings()
if not payload.guild_id:
return
guild = self.bot.get_guild(payload.guild_id)
# Ensure head state stays accurate even if settings/users change at runtime
try:
hg = cfg(self.bot).int('home_guild_id', 0)
base_guild = self.bot.get_guild(hg) or guild
await self._maybe_transition_head_state(base_guild)
except Exception:
pass
member = await self._get_member(guild, payload.user_id)
if not member or member.bot:
return
dm = self.bot.data_manager
# ----- Original accept-based flows -----
try:
if is_accept(payload.emoji):
# RULES
if self.rules_msg_id and payload.message_id == self.rules_msg_id:
role = guild.get_role(self.rules_role)
if role:
try:
await member.add_roles(role, reason="Agreed to rules")
except Exception:
pass
if member.id not in _as_list(dm.get('agreed_rules')):
dm.add('agreed_rules', int(member.id))
# ENGAGEMENT
elif self.engage_msg_id and payload.message_id == self.engage_msg_id:
role = guild.get_role(self.engage_role)
if role:
try:
await member.add_roles(role, reason="Agreed to engagement")
except Exception:
pass
if member.id not in _as_list(dm.get('agreed_engagement')):
dm.add('agreed_engagement', int(member.id))
# NICKNAME (debounced + idempotent)
elif self.nick_msg_id and payload.message_id == self.nick_msg_id:
now = time.monotonic()
key = (guild.id, member.id)
last = self._nick_debounce.get(key, 0.0)
if now - last < 10.0:
return # debounce bursty multi-emoji reacts
self._nick_debounce[key] = now
# If any pending review already exists for this user, do nothing
has_pending_review = any(
r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending'
for r in _as_list(dm.get('nick_reviews'))
)
if has_pending_review:
return
# In-flight lock to avoid concurrent duplicate openings
if key in self._nick_lock:
return
self._nick_lock.add(key)
try:
# Clear stale marker if no active review, then open exactly one
dm.remove('nick_claim_pending', lambda x: x == member.id)
nn = self.bot.get_cog('NickNudgeCog')
if nn and hasattr(nn, 'ensure_pending_and_maybe_open'):
try:
await nn.ensure_pending_and_maybe_open(guild, member, source="claim")
except Exception:
pass
finally:
self._nick_lock.discard(key)
except Exception:
pass
# ----- Crew roles hub (custom emoji toggles + Fedaykin request) -----
try:
if self.crew_msg_id and payload.message_id == self.crew_msg_id and payload.emoji.id:
# Gate: must have Full Access to claim/request crew roles
if not self._has_full_initiated(member):
await self._remove_reaction_silent(guild, payload.channel_id, payload.message_id, payload.emoji, member)
return
# Harvester / Escort
if payload.emoji.id == self.emoji_harvest_id and self.role_harvest_id:
role = guild.get_role(self.role_harvest_id)
if role:
try:
await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}")
except Exception:
pass
return
if payload.emoji.id == self.emoji_escort_id and self.role_escort_id:
role = guild.get_role(self.role_escort_id)
if role:
try:
await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}")
except Exception:
pass
return
# Fedaykin -> open approval card (only one pending allowed)
if payload.emoji.id == self.emoji_fedaykin_id:
fed_role = guild.get_role(self.role_fedaykin_id) if self.role_fedaykin_id else None
if fed_role and fed_role in member.roles:
return # already has Fedaykin
pending = next((x for x in _as_list(dm.get('fedaykin_requests'))
if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "pending"), None)
if pending:
return
hub_msg, _ = await self._find_hub_message(guild)
hub_id = hub_msg.id if hub_msg else self.crew_msg_id
await self._post_fedaykin_card(guild, member, hub_id)
return
except Exception:
pass
# Only the original (agreements) flow affects Full Access
await self.maybe_apply_full_access(member)
@commands.Cog.listener()
async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent):
# hot-reload settings
self._refresh_settings()
if not payload.guild_id:
return
guild = self.bot.get_guild(payload.guild_id)
# Ensure head state stays accurate even if settings/users change at runtime
try:
hg = cfg(self.bot).int('home_guild_id', 0)
base_guild = self.bot.get_guild(hg) or guild
await self._maybe_transition_head_state(base_guild)
except Exception:
pass
member = await self._get_member(guild, payload.user_id)
if not member or member.bot:
return
dm = self.bot.data_manager
# ----- Original accept-based flows -----
try:
if is_accept(payload.emoji):
# RULES
if self.rules_msg_id and payload.message_id == self.rules_msg_id:
dm.remove('agreed_rules', lambda x: x == member.id)
role = guild.get_role(self.rules_role)
if role and role in member.roles:
try:
await member.remove_roles(role, reason="Rules un-ticked")
except Exception:
pass
# ENGAGEMENT
elif self.engage_msg_id and payload.message_id == self.engage_msg_id:
dm.remove('agreed_engagement', lambda x: x == member.id)
role = guild.get_role(self.engage_role)
if role and role in member.roles:
try:
await member.remove_roles(role, reason="Engagement un-ticked")
except Exception:
pass
# NICKNAME: clear only if no other accept left
elif self.nick_msg_id and payload.message_id == self.nick_msg_id:
still_has_accept = await self._user_has_any_accept(
guild, payload.channel_id, payload.message_id, member.id
)
if not still_has_accept:
dm.remove('agreed_nickname', lambda x: x == member.id)
dm.remove('nick_claim_pending', lambda x: x == member.id)
dm.remove('nick_verified', lambda x: x == member.id)
except Exception:
pass
# ----- Crew roles hub (custom emoji) -----
try:
if self.crew_msg_id and payload.message_id == self.crew_msg_id and payload.emoji.id:
# Harvester
if payload.emoji.id == self.emoji_harvest_id and self.role_harvest_id:
role = guild.get_role(self.role_harvest_id)
if role and role in member.roles:
try:
await member.remove_roles(role, reason=f"Reaction role via hub {self.crew_msg_id} (unreact)")
except Exception:
pass
return
# Escort
if payload.emoji.id == self.emoji_escort_id and self.role_escort_id:
role = guild.get_role(self.role_escort_id)
if role and role in member.roles:
try:
await member.remove_roles(role, reason=f"Reaction role via hub {self.crew_msg_id} (unreact)")
except Exception:
pass
return
# Fedaykin unreact -> remove role + mark request accordingly
if payload.emoji.id == self.emoji_fedaykin_id:
fed_role = guild.get_role(self.role_fedaykin_id) if self.role_fedaykin_id else None
# Find a related request if any (prefer pending; else last approved)
req = None
pend = [x for x in _as_list(dm.get('fedaykin_requests'))
if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "pending"]
if pend:
req = pend[-1]
req["status"] = "withdrawn"
else:
appr = [x for x in _as_list(dm.get('fedaykin_requests'))
if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "approved"]
if appr:
req = appr[-1]
req["status"] = "revoked"
# Always remove the role on unreact (per requirement)
if fed_role and fed_role in member.roles:
try:
await member.remove_roles(fed_role, reason="Fedaykin reaction removed")
except Exception:
pass
if req:
req["decision_ts"] = datetime.now(timezone.utc).timestamp()
await self._save_fedaykin_request(req)
actor = guild.me # system action
status = req["status"]
# Update card footer if we still have it
await self._edit_review_message_footer(
req,
f"**Decision:** {status.capitalize()} (user unreact) at {discord.utils.format_dt(datetime.now(timezone.utc), style='f')}",
clear_view=True,
)
# Modlog
try:
await self._modlog_decision(guild, status, member, actor, req)
except Exception:
pass
return
except Exception:
pass
# Only the original (agreements) flow affects Full Access
await self.maybe_apply_full_access(member)
async def setup(bot):
await bot.add_cog(ReactionRoleCog(bot))

View File

View File

@ -0,0 +1,902 @@
# modules/spicepay/spicepay.py
import re
import time
import asyncio
from typing import List, Dict, Tuple, Optional
import discord
from discord.ext import commands
from discord import app_commands
from modules.common.settings import cfg
# Accept both for backward compatibility; display uses "Refiner"
VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"}
# ------------------------ emoji helpers ------------------------
def _emoji_str(bot: commands.Bot, emoji_id: Optional[int], fallback: str) -> str:
if emoji_id:
e = bot.get_emoji(int(emoji_id))
if e:
return str(e)
return fallback
# ------------------------ core math ------------------------
def _normalize_roles(raw: str) -> List[str]:
"""
Accepts flexible input; maps common aliases to canonical role keys.
New canonical: refiner_owner (old: lsr_owner still accepted).
"""
if not raw:
return []
txt = raw.strip().lower()
tokens = re.split(r"[,\s]+", txt)
out = set()
for tok in tokens:
if not tok:
continue
t = tok.replace("-", "").replace("_", "")
if t in ("crawler", "cr", "craw", "crawlerowner"):
out.add("crawler_owner")
elif t in ("carrier", "ca", "car", "carrierowner"):
out.add("carrier_owner")
elif t in ("refiner", "refinery", "refineryowner", "refinerowner", "lsr", "lsrowner", "largespicerefinery"):
out.add("refiner_owner")
elif t in ("none", "no", "nil"):
return []
return list(out)
def calculate_spice_distribution(
spice_total: int,
participants: List[Dict],
*,
base_weight: float = 25.0,
carrier_bonus: float = 12.5,
crawler_bonus: float = 12.5,
lsr_cut_percent: float = 10.0, # config key name retained; UI calls it "refinery cut"
lsr_required: bool = True, # True for melange payouts
) -> Tuple[List[Dict], List[str]]:
errors: List[str] = []
result: List[Dict] = []
if not isinstance(spice_total, int) or spice_total < 0:
return [], ["Spice total must be a non-negative integer."]
if base_weight < 0 or carrier_bonus < 0 or crawler_bonus < 0:
return [], ["Weights must be non-negative."]
if lsr_cut_percent < 0 or lsr_cut_percent > 100:
return [], ["Refinery cut percent must be between 0 and 100."]
seen_names = set()
refiner_owners: List[str] = []
cleaned: List[Dict] = []
for i, p in enumerate(participants):
if not isinstance(p, dict):
errors.append(f"Participant at index {i} is not a dict.")
continue
name = p.get('name')
participation = p.get('participation', 0)
roles = p.get('roles', [])
if not name or not isinstance(name, str):
errors.append(f"Participant at index {i} has an invalid or missing name.")
continue
name = name.strip()
if name in seen_names:
errors.append(f"Duplicate participant name: '{name}'.")
continue
seen_names.add(name)
if isinstance(participation, str):
participation = participation.strip().replace("%", "")
try:
participation = float(participation)
except Exception:
errors.append(f"Participant '{name}' has invalid participation value.")
continue
if not isinstance(participation, (int, float)) or not (0 <= participation <= 100):
errors.append(f"Participant '{name}' has invalid participation: {participation}. Must be 0100.")
continue
if isinstance(roles, str):
roles = _normalize_roles(roles)
if not isinstance(roles, list):
errors.append(f"Participant '{name}' has invalid roles payload.")
continue
invalid = [r for r in roles if r not in VALID_ROLES]
if invalid:
errors.append(f"Participant '{name}' has invalid roles: {invalid}.")
continue
if 'refiner_owner' in roles or 'lsr_owner' in roles:
refiner_owners.append(name)
cleaned.append({'name': name, 'participation': float(participation), 'roles': roles})
# Refiner checks depend on payout mode
if lsr_required:
if len(refiner_owners) > 1:
errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only one is allowed.")
elif len(refiner_owners) == 0:
errors.append("No refiner owner found. Exactly one is required for melange payouts.")
else:
if len(refiner_owners) > 1:
errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only zero or one is allowed for sand payouts.")
if errors:
return [], errors
refiner_owner = refiner_owners[0] if refiner_owners else None
refinery_cut = int(spice_total * (lsr_cut_percent / 100.0)) if (lsr_required and refiner_owner) else 0
distributable = spice_total - refinery_cut
# Compute weights
weights: Dict[str, float] = {}
total_weight = 0.0
for p in cleaned:
w = float(base_weight) * (p['participation'] / 100.0)
if 'carrier_owner' in p['roles']:
w += float(carrier_bonus)
if 'crawler_owner' in p['roles']:
w += float(crawler_bonus)
weights[p['name']] = w
total_weight += w
if total_weight <= 0:
return [], [
"Total effective weight is zero. Increase participation or weighting factors "
"(e.g., raise base ×% and/or bonuses) so at least one participant has non-zero weight."
]
# Proportional allocation
spice_raw: Dict[str, int] = {}
total_floored = 0
for p in cleaned:
name = p['name']
w = weights.get(name, 0.0)
share = int((w / total_weight) * distributable)
spice_raw[name] = share
total_floored += share
remainder = distributable - total_floored
if refiner_owner:
spice_raw[refiner_owner] = spice_raw.get(refiner_owner, 0) + refinery_cut + remainder
else:
top = max(spice_raw.items(), key=lambda kv: kv[1])[0]
spice_raw[top] += remainder
result = [{
'name': p['name'],
'spice': int(spice_raw.get(p['name'], 0)),
'participation': p['participation'],
'roles': p['roles'],
} for p in cleaned]
return result, []
# ------------------------ formatting ------------------------
def _fmt_roles_short(roles: List[str]) -> str:
if not roles:
return "Escort"
short = []
for r in roles:
if r in ("refiner_owner", "lsr_owner"): short.append("Refiner")
elif r == "crawler_owner": short.append("Crawler")
elif r == "carrier_owner": short.append("Carrier")
return ",".join(short) if short else "Escort"
def _format_table(results: List[Dict], unit_header: str, unit_suffix: str) -> str:
rows = sorted(results, key=lambda r: (-r['spice'], r['name'].lower()))
any_zero = any((r.get('participation') or 0) <= 0 for r in rows)
own_header = "Owner of/Role"
name_w = max(4, *(len(r['name']) for r in rows)) if rows else 4
own_w = max(len(own_header), *(len(_fmt_roles_short(r['roles'])) for r in rows)) if rows else len(own_header)
pay_w = max(6, len(unit_header))
header = f"{'Name'.ljust(name_w)} {'Active %'.rjust(8)} {own_header.ljust(own_w)} {unit_header.rjust(pay_w)}"
sep = "-" * len(header)
lines = [header, sep]
for r in rows:
name = r['name'].ljust(name_w)
pct_val = int(round(r.get('participation') or 0))
pct = f"{pct_val}%"
if pct_val == 0:
pct += "*" # owner-only marker
pct = pct.rjust(8)
owned = _fmt_roles_short(r.get('roles', [])).ljust(own_w)
amount = f"{r['spice']} {unit_suffix}"
lines.append(f"{name} {pct} {owned} {amount}")
if any_zero:
lines.append("")
lines.append("* 0% = owner only (did not actively join the run)")
return "```\n" + "\n".join(lines) + "\n```"
# -------- Modals --------
class _SetupModal(discord.ui.Modal, title="Spice Pay — Setup"):
def __init__(self, cog: "SpicePayCog", pre_participants: Optional[int]):
super().__init__()
self.cog = cog
self.pre_participants = pre_participants
self.total_field = discord.ui.TextInput(
label="Total spice yield (sand)",
placeholder="e.g. 12345",
required=True,
max_length=10
)
self.count_field = discord.ui.TextInput(
label="Participants (incl. owners)",
placeholder="Total number of people (add refiner/carrier/crawler owners too)",
required=True,
max_length=45, # allow a clearer hint
default=str(pre_participants) if pre_participants is not None else None
)
self.add_item(self.total_field)
self.add_item(self.count_field)
async def on_submit(self, interaction: discord.Interaction):
try:
total = int(self.total_field.value.strip())
if total < 0:
raise ValueError
except Exception:
return await interaction.response.send_message("❌ Total must be a non-negative integer.", ephemeral=True)
try:
count = int(str(self.count_field.value).strip())
if not (1 <= count <= 25):
raise ValueError
except Exception:
return await interaction.response.send_message("❌ Participants must be between 1 and 25.", ephemeral=True)
await self.cog.start_session(interaction, total, count)
class _ParticipantModal(discord.ui.Modal):
def __init__(self, cog: "SpicePayCog", session_key, index: int, existing: Optional[Dict]):
super().__init__(title=f"Participant {index}")
self.cog = cog
self.session_key = session_key
self.index = index
ex = existing or {}
self.name = discord.ui.TextInput(
label="Name",
placeholder="Player name (or @mention text)",
required=True,
max_length=64,
default=ex.get("name") if ex else None
)
self.part = discord.ui.TextInput(
label="Active participation % (0100)",
placeholder="How much they actively joined the run (0..100; 0% = owner only)",
required=True,
max_length=45,
default=str(int(ex["participation"])) if "participation" in ex else None
)
self.roles = discord.ui.TextInput(
label="Owner of (optional)",
placeholder="refiner, crawler, carrier — leave empty if escort/non-owner",
required=False,
max_length=64,
default=",".join(ex["roles"]) if ex and ex.get("roles") else None
)
self.add_item(self.name)
self.add_item(self.part)
self.add_item(self.roles)
async def on_submit(self, interaction: discord.Interaction):
sess = self.cog.sessions.get(self.session_key)
if not sess:
return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True)
name = self.name.value.strip()
part_raw = str(self.part.value).strip().replace("%", "")
try:
participation = float(part_raw)
except Exception:
return await interaction.response.send_message("❌ Participation must be a number (0100).", ephemeral=True)
if participation < 0 or participation > 100:
return await interaction.response.send_message("❌ Participation must be 0100.", ephemeral=True)
roles = _normalize_roles(self.roles.value or "")
idx = self.index - 1
while len(sess["participants"]) < idx + 1:
sess["participants"].append({})
sess["participants"][idx] = {"name": name, "participation": participation, "roles": roles}
if sess["next_index"] == self.index and sess["next_index"] < sess["count"]:
sess["next_index"] += 1
await self.cog.render_progress(interaction, self.session_key)
class _MelangeModal(discord.ui.Modal, title="Set melange refinery yield"):
def __init__(self, cog: "SpicePayCog", session_key):
super().__init__()
self.cog = cog
self.session_key = session_key
self.amount = discord.ui.TextInput(
label="Melange refinery yield (integer)",
placeholder="e.g. 123",
required=True,
max_length=10
)
self.add_item(self.amount)
async def on_submit(self, interaction: discord.Interaction):
sess = self.cog.sessions.get(self.session_key)
if not sess:
return await interaction.response.send_message("Session expired.", ephemeral=True)
val = self.amount.value.strip()
try:
m = int(val)
if m < 0:
raise ValueError
except Exception:
return await interaction.response.send_message("❌ Melange total must be a non-negative integer.", ephemeral=True)
sess["melange_total"] = m
await self.cog.render_progress(interaction, self.session_key)
class _WeightsModal(discord.ui.Modal, title="Adjust weighting factors"):
def __init__(self, cog: "SpicePayCog", session_key):
super().__init__()
self.cog = cog
self.session_key = session_key
sess = self.cog.sessions.get(session_key) or {}
w = (sess.get("weights") or {})
bw = w.get("base", self.cog.base_weight)
cab = w.get("carrier", self.cog.carrier_bonus)
crb = w.get("crawler", self.cog.crawler_bonus)
lsr = w.get("lsr", self.cog.lsr_cut_percent)
self.base = discord.ui.TextInput(label="Base × active %", required=False, max_length=10, placeholder=str(bw))
self.carrier = discord.ui.TextInput(label="Carrier bonus (+)", required=False, max_length=10, placeholder=str(cab))
self.crawler = discord.ui.TextInput(label="Crawler bonus (+)", required=False, max_length=10, placeholder=str(crb))
self.lsr = discord.ui.TextInput(label="Refinery cut % (melange)", required=False, max_length=10, placeholder=str(lsr))
self.add_item(self.base)
self.add_item(self.carrier)
self.add_item(self.crawler)
self.add_item(self.lsr)
async def on_submit(self, interaction: discord.Interaction):
sess = self.cog.sessions.get(self.session_key)
if not sess:
return await interaction.response.send_message("Session expired.", ephemeral=True)
def _parse_nonneg(opt_str, pct=False):
if not opt_str:
return None
s = str(opt_str).strip().replace("%", "")
try:
v = float(s)
except Exception:
return "bad"
if v < 0:
return "bad"
if pct and (v < 0 or v > 100):
return "bad"
return v
b = _parse_nonneg(self.base.value)
cab = _parse_nonneg(self.carrier.value)
crb = _parse_nonneg(self.crawler.value)
lsr = _parse_nonneg(self.lsr.value, pct=True)
if "bad" in (b, cab, crb, lsr):
return await interaction.response.send_message(
"❌ Invalid values. Use non-negative numbers; refinery % must be 0100.",
ephemeral=True
)
w = sess.get("weights") or {}
if b is not None: w["base"] = b
if cab is not None: w["carrier"] = cab
if crb is not None: w["crawler"] = crb
if lsr is not None: w["lsr"] = lsr
sess["weights"] = w
# Soft warning for extreme values
if any(v is not None and v > 1000 for v in (b, cab, crb)):
try:
await interaction.response.send_message(
"⚠️ Very large weights dont change ratios (we normalize), but may affect rounding slightly.",
ephemeral=True
)
except discord.InteractionResponded:
await interaction.followup.send(
"⚠️ Very large weights dont change ratios (we normalize), but may affect rounding slightly.",
ephemeral=True
)
return await self.cog.render_progress(interaction, self.session_key)
await self.cog.render_progress(interaction, self.session_key)
# ------------------------ Views ------------------------
class _HelpView(discord.ui.View):
def __init__(self, cog: "SpicePayCog", session_key: tuple):
super().__init__(timeout=600) # keep session alive
self.cog = cog
self.session_key = session_key
async def on_timeout(self):
pass
@discord.ui.button(label="Return to setup", style=discord.ButtonStyle.primary)
async def back(self, interaction: discord.Interaction, _btn: discord.ui.Button):
await self.cog.render_progress(interaction, self.session_key)
@discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary)
async def open_weights(self, interaction: discord.Interaction, _btn: discord.ui.Button):
await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key))
class _WizardView(discord.ui.View):
def __init__(self, cog: "SpicePayCog", session_key: tuple):
super().__init__(timeout=600)
self.cog = cog
self.session_key = session_key
async def on_timeout(self):
pass
@discord.ui.button(label="Add / Edit participant", style=discord.ButtonStyle.primary, row=0)
async def add_edit(self, interaction: discord.Interaction, _button: discord.ui.Button):
sess = self.cog.sessions.get(self.session_key)
if not sess:
return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True)
idx = sess["next_index"]
if idx > sess["count"]:
idx = sess["count"]
existing = sess["participants"][idx-1] if 0 <= idx-1 < len(sess["participants"]) else None
await interaction.response.send_modal(_ParticipantModal(self.cog, self.session_key, idx, existing))
@discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary, row=0)
async def previous(self, interaction: discord.Interaction, _button: discord.ui.Button):
sess = self.cog.sessions.get(self.session_key)
if not sess:
return await interaction.response.send_message("Session expired.", ephemeral=True)
sess["next_index"] = max(1, sess["next_index"] - 1)
await self.cog.render_progress(interaction, self.session_key)
@discord.ui.button(label="Next", style=discord.ButtonStyle.secondary, row=0)
async def next(self, interaction: discord.Interaction, _button: discord.ui.Button):
sess = self.cog.sessions.get(self.session_key)
if not sess:
return await interaction.response.send_message("Session expired.", ephemeral=True)
sess["next_index"] = min(sess["count"], sess["next_index"] + 1)
await self.cog.render_progress(interaction, self.session_key)
@discord.ui.button(label="Toggle payout: Sand/Melange", style=discord.ButtonStyle.secondary, row=1)
async def toggle_payout(self, interaction: discord.Interaction, _button: discord.ui.Button):
sess = self.cog.sessions.get(self.session_key)
if not sess:
return await interaction.response.send_message("Session expired.", ephemeral=True)
sess["payout_type"] = "melange" if sess.get("payout_type") == "sand" else "sand"
if sess["payout_type"] == "melange" and not sess.get("melange_total"):
return await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key))
await self.cog.render_progress(interaction, self.session_key)
@discord.ui.button(label="Set melange refinery yield", style=discord.ButtonStyle.primary, row=1)
async def set_melange(self, interaction: discord.Interaction, _button: discord.ui.Button):
await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key))
@discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary, row=1)
async def adjust_weights(self, interaction: discord.Interaction, _button: discord.ui.Button):
await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key))
@discord.ui.button(label="Weights help", style=discord.ButtonStyle.secondary, row=2)
async def weights_help(self, interaction: discord.Interaction, _button: discord.ui.Button):
help_txt = (
"**How the split works**\n"
"• **Participants are people** (or a guild as one person). Vehicles/refinery are **owned** by someone.\n"
"• Everyone gets a *weight*: **Base × active %** + **bonuses** (Carrier/Crawler if they own them).\n"
"• We split the pot **proportionally** to those weights (normalized; only ratios matter).\n"
"• **Melange payout** only: a **refinery cut %** is taken first and given to the **Refiner owner**.\n"
"• People with **0%** are treated as **owner only** (get owner bonuses/cut but didnt actively join the run).\n"
"• Rounding leftovers go to the Refiner (melange) or the top earner (sand).\n\n"
"_Tap **Return to setup** below to go back, or **Adjust weighting factors** to change numbers._"
)
try:
await interaction.response.edit_message(content=help_txt, view=_HelpView(self.cog, self.session_key))
except discord.InteractionResponded:
await interaction.followup.send(help_txt, ephemeral=True, view=_HelpView(self.cog, self.session_key))
# ---------- Presets row ----------
@discord.ui.button(label="Preset: Owner-heavy", style=discord.ButtonStyle.secondary, row=3)
async def preset_owner(self, interaction: discord.Interaction, _btn: discord.ui.Button):
await self.cog.apply_preset(interaction, self.session_key, preset="owner")
@discord.ui.button(label="Preset: Participation-heavy", style=discord.ButtonStyle.secondary, row=3)
async def preset_part(self, interaction: discord.Interaction, _btn: discord.ui.Button):
await self.cog.apply_preset(interaction, self.session_key, preset="participation")
@discord.ui.button(label="Preset: Fair (server defaults)", style=discord.ButtonStyle.secondary, row=3)
async def preset_fair(self, interaction: discord.Interaction, _btn: discord.ui.Button):
await self.cog.apply_preset(interaction, self.session_key, preset="fair")
@discord.ui.button(label="Preset: Even split", style=discord.ButtonStyle.secondary, row=3)
async def preset_even(self, interaction: discord.Interaction, _btn: discord.ui.Button):
await self.cog.apply_preset(interaction, self.session_key, preset="even")
@discord.ui.button(label="Save as my defaults", style=discord.ButtonStyle.success, row=3)
async def save_defaults(self, interaction: discord.Interaction, _btn: discord.ui.Button):
await self.cog.save_user_defaults(interaction, self.session_key)
@discord.ui.button(label="Finish", style=discord.ButtonStyle.success, row=2)
async def finish(self, interaction: discord.Interaction, _button: discord.ui.Button):
await self.cog.finish_and_show(interaction, self.session_key)
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger, row=2)
async def cancel(self, interaction: discord.Interaction, _button: discord.ui.Button):
self.cog.sessions.pop(self.session_key, None)
await interaction.response.edit_message(content="Spice pay session cancelled.", view=None)
class _StartView(discord.ui.View):
def __init__(self, cog: "SpicePayCog", participants: Optional[int]):
super().__init__(timeout=600) # was 120
self.cog = cog
self.participants = participants
@discord.ui.button(label="Open setup", style=discord.ButtonStyle.primary)
async def open_setup(self, interaction: discord.Interaction, _btn: discord.ui.Button):
try:
await interaction.response.send_modal(_SetupModal(self.cog, self.participants))
except Exception as e:
print("[spicepay] fallback button failed:", repr(e))
await interaction.response.send_message("Still couldnt open the modal. Do I have the right permissions here?", ephemeral=True)
# ------------------------ Cog ------------------------
class SpicePayCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.sessions: Dict[tuple, Dict] = {}
r = cfg(bot)
def _f(key, default):
try:
return float(r.get(key, str(default)))
except Exception:
return float(default)
self.base_weight = _f('spicepay_base_weight', 25.0)
self.carrier_bonus = _f('spicepay_carrier_bonus', 12.5)
self.crawler_bonus = _f('spicepay_crawler_bonus', 12.5)
self.lsr_cut_percent = _f('spicepay_lsr_cut_percent', 10.0) # keep key; UI calls it refinery cut
def _i(key):
try:
v = r.get(key, "")
return int(v) if v else None
except Exception:
return None
self.emoji_sand_id = _i('emoji_sand_id')
self.emoji_melange_id = _i('emoji_melange_id')
self.emoji_cc_id = _i('emoji_carrier_crawler_id')
# Session reaper (clean up sessions older than 60 minutes)
self._reaper_task = asyncio.create_task(self._session_reaper())
def cog_unload(self):
try:
self._reaper_task.cancel()
except Exception:
pass
async def _session_reaper(self):
await self.bot.wait_until_ready()
while not self.bot.is_closed():
now = time.time()
ttl = 3600 # 60 min
dead = [k for k, s in list(self.sessions.items())
if now - s.get("created_ts", now) > ttl]
for k in dead:
self.sessions.pop(k, None)
await asyncio.sleep(300) # sweep every 5 min
# ----- user prefs helpers (persisted to data_file) -----
def _get_user_prefs(self, user_id: int) -> Optional[Dict]:
prefs = self.bot.data_manager.get('spicepay_prefs')
for row in prefs:
if row.get('user_id') == user_id:
return row
return None
def _save_user_prefs(self, user_id: int, weights: Dict[str, float]):
# overwrite existing
self.bot.data_manager.remove('spicepay_prefs', lambda r: r.get('user_id') == user_id)
self.bot.data_manager.add('spicepay_prefs', {
'user_id': user_id,
'weights': {
'base': float(weights.get('base', self.base_weight)),
'carrier': float(weights.get('carrier', self.carrier_bonus)),
'crawler': float(weights.get('crawler', self.crawler_bonus)),
'lsr': float(weights.get('lsr', self.lsr_cut_percent)),
},
'saved_ts': time.time()
})
# ----- commands -----
@app_commands.command(name="spicepay", description="Calculate spice payout via a guided wizard (opens a form)")
@app_commands.describe(
participants="Total number of people involved (include owners of refiner/carrier/crawler)",
force_new="Start a fresh session even if one is active"
)
async def spicepay(self, interaction: discord.Interaction, participants: Optional[int] = None, force_new: Optional[bool] = False):
key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
if not force_new and key in self.sessions:
return await self.render_progress(interaction, key, new_message=True)
try:
await interaction.response.send_modal(_SetupModal(self, participants))
except Exception as e:
print("[spicepay] send_modal failed:", repr(e))
try:
await interaction.response.defer(ephemeral=True, thinking=False)
except Exception:
pass
await interaction.followup.send(
"Couldnt open the setup modal automatically. Click below to start:",
ephemeral=True,
view=_StartView(self, participants)
)
@app_commands.command(name="spicepay_resume", description="Reopen your active spice pay session")
async def spicepay_resume(self, interaction: discord.Interaction):
key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
if key in self.sessions:
await self.render_progress(interaction, key, new_message=True)
else:
await interaction.response.send_message(
"No active session found. Run **/spicepay** to start a new one.",
ephemeral=True
)
@app_commands.command(name="spicepay_cancel", description="Cancel your active spicepay session")
async def spicepay_cancel(self, interaction: discord.Interaction):
key = (interaction.guild.id if interaction.guild else 0, interaction.user.id)
if key in self.sessions:
self.sessions.pop(key, None)
await interaction.response.send_message("Cancelled your spice pay session.", ephemeral=True)
else:
await interaction.response.send_message("No active spice pay session.", ephemeral=True)
@app_commands.command(name="spicepay_config", description="Show the current spicepay weight settings")
async def spicepay_config(self, interaction: discord.Interaction):
txt = (
"**SpicePay configuration**\n"
f"- Refinery cut (melange payouts): **{self.lsr_cut_percent}%**\n"
f"- Base weight: **{self.base_weight} × active %**\n"
f"- Carrier bonus: **+{self.carrier_bonus}**\n"
f"- Crawler bonus: **+{self.crawler_bonus}**\n\n"
"_Set via environment variables or your INI. Restart the bot after changing._"
)
await interaction.response.send_message(txt, ephemeral=True)
# ----- session helpers -----
async def start_session(self, interaction: discord.Interaction, total: int, count: int):
g = interaction.guild
key = (g.id if g else 0, interaction.user.id)
# seed weights from user prefs if present
w = {}
user_prefs = self._get_user_prefs(interaction.user.id)
if user_prefs and isinstance(user_prefs.get('weights'), dict):
w = {
'base': float(user_prefs['weights'].get('base', self.base_weight)),
'carrier': float(user_prefs['weights'].get('carrier', self.carrier_bonus)),
'crawler': float(user_prefs['weights'].get('crawler', self.crawler_bonus)),
'lsr': float(user_prefs['weights'].get('lsr', self.lsr_cut_percent)),
}
# Auto-fill initiator as participant #1 (easy to adjust)
display = interaction.user.display_name if isinstance(interaction.user, discord.Member) else interaction.user.name
participants_seed = [{'name': display, 'participation': 50.0, 'roles': []}]
self.sessions[key] = {
"total": total, # sand yield
"count": count,
"participants": participants_seed,
"next_index": 1, # stays on 1 so they can review/edit their autofill
"created_ts": time.time(),
"payout_type": "sand", # 'sand' or 'melange'
"melange_total": None, # required if payout_type == 'melange'
"weights": w # optional overrides: base, carrier, crawler, lsr(refinery)
}
await self.render_progress(interaction, key, new_message=True)
async def render_progress(self, interaction: discord.Interaction, key: tuple, new_message: bool = False):
sess = self.sessions.get(key)
if not sess:
return await interaction.response.send_message("Session expired.", ephemeral=True)
sand_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️")
mel_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪")
cc_emoji = _emoji_str(self.bot, self.emoji_cc_id, "🚛")
w = sess.get("weights") or {}
bw = w.get("base", self.base_weight)
cab = w.get("carrier", self.carrier_bonus)
crb = w.get("crawler", self.crawler_bonus)
lsrp = w.get("lsr", self.lsr_cut_percent)
filled = sum(1 for p in sess["participants"] if p.get("name"))
idx = sess["next_index"]
total_pct = int(round(sum((p.get("participation") or 0) for p in sess["participants"])))
payout_line = f"Payout: **Sand Spice** {sand_emoji}"
if sess.get("payout_type") == "melange":
if sess.get("melange_total") is None:
payout_line = f"Payout: **Spice Melange** {mel_emoji} — _set melange refinery yield_"
else:
payout_line = f"Payout: **Spice Melange** {mel_emoji} — total **{sess['melange_total']}**"
progress = (
f"**Spice Pay Setup** — sand yield: **{sess['total']}** {sand_emoji} | participants: **{sess['count']}** | "
f"total active ≈ **{total_pct}%**\n"
f"{payout_line}\n"
f"Filled: **{filled}/{sess['count']}** — next: **{idx}**\n"
f"_Weighting factors: Refinery cut {lsrp}% (melange only) | Base {bw}× active % | Carrier +{cab} | Crawler +{crb}"
f"normalized; only ratios matter._ {cc_emoji}"
)
preview_lines = []
for i in range(sess["count"]):
p = sess["participants"][i] if i < len(sess["participants"]) else None
if p and p.get("name"):
mark = " (owner only)" if int(round(p.get("participation") or 0)) == 0 else ""
preview_lines.append(f"{i+1}. {p['name']}{int(p['participation'])}%{mark}{_fmt_roles_short(p.get('roles', []))}")
else:
preview_lines.append(f"{i+1}. _empty_")
content = progress + "\n" + "\n".join(preview_lines)
view = _WizardView(self, key)
try:
if new_message:
await interaction.response.send_message(content, ephemeral=True, view=view)
else:
await interaction.response.edit_message(content=content, view=view)
except discord.InteractionResponded:
await interaction.followup.send(content, ephemeral=True, view=view)
async def apply_preset(self, interaction: discord.Interaction, key: tuple, preset: str):
sess = self.sessions.get(key)
if not sess:
return await interaction.response.send_message("Session expired.", ephemeral=True)
if preset == "owner":
w = {'base': 15.0, 'carrier': 25.0, 'crawler': 25.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)}
elif preset == "participation":
w = {'base': 50.0, 'carrier': 5.0, 'crawler': 5.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)}
elif preset == "even":
w = {'base': 1.0, 'carrier': 0.0, 'crawler': 0.0, 'lsr': 0.0}
else: # "fair" -> server defaults
w = {'base': self.base_weight, 'carrier': self.carrier_bonus, 'crawler': self.crawler_bonus, 'lsr': self.lsr_cut_percent}
sess['weights'] = w
await self.render_progress(interaction, key)
async def save_user_defaults(self, interaction: discord.Interaction, key: tuple):
sess = self.sessions.get(key)
if not sess:
return await interaction.response.send_message("Session expired.", ephemeral=True)
w = sess.get('weights') or {
'base': self.base_weight,
'carrier': self.carrier_bonus,
'crawler': self.crawler_bonus,
'lsr': self.lsr_cut_percent
}
self._save_user_prefs(interaction.user.id, w)
try:
await interaction.response.send_message("Saved these weighting factors as your defaults ✅", ephemeral=True)
except discord.InteractionResponded:
await interaction.followup.send("Saved these weighting factors as your defaults ✅", ephemeral=True)
async def finish_and_show(self, interaction: discord.Interaction, key: tuple):
sess = self.sessions.get(key)
if not sess:
return await interaction.response.send_message("Session expired.", ephemeral=True)
payout_type = sess.get("payout_type", "sand")
if payout_type == "melange" and sess.get("melange_total") is None:
return await interaction.response.send_message("❌ Set the melange refinery yield first.", ephemeral=True)
w = sess.get("weights") or {}
bw = w.get("base", self.base_weight)
cab = w.get("carrier", self.carrier_bonus)
crb = w.get("crawler", self.crawler_bonus)
lsrp = w.get("lsr", self.lsr_cut_percent)
participants = []
names_seen = set()
for i in range(sess["count"]):
p = sess["participants"][i] if i < len(sess["participants"]) else {}
name = (p.get("name") or f"User{i+1}").strip()
if name in names_seen:
name = f"{name}_{i+1}"
names_seen.add(name)
part = p.get("participation", 0)
roles = p.get("roles", [])
participants.append({"name": name, "participation": part, "roles": roles})
if payout_type == "sand":
total = int(sess["total"])
lsr_req = False
lsr_pct = 0.0
unit_header = "Sand Spice"
unit_suffix = "Sand Spice"
title_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️")
payout_name = "Sand Spice"
else:
total = int(sess["melange_total"])
lsr_req = True
lsr_pct = float(lsrp)
unit_header = "Spice Melange"
unit_suffix = "Spice Melange"
title_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪")
payout_name = "Spice Melange"
results, errors = calculate_spice_distribution(
total,
participants,
base_weight=bw,
carrier_bonus=cab,
crawler_bonus=crb,
lsr_cut_percent=lsr_pct,
lsr_required=lsr_req
)
if errors:
err_text = "❌ **Problems found:**\n" + "\n".join(f"- {e}" for e in errors) + "\n\n" \
"Use **Add / Edit participant** to fix, or **Cancel**."
try:
await interaction.response.edit_message(content=err_text, view=_WizardView(self, key))
except discord.InteractionResponded:
await interaction.followup.send(err_text, ephemeral=True, view=_WizardView(self, key))
return
table = _format_table(results, unit_header, unit_suffix)
class _PostView(discord.ui.View):
def __init__(self, outer: "SpicePayCog"):
super().__init__(timeout=600)
self.outer = outer
@discord.ui.button(label="Post to channel", style=discord.ButtonStyle.primary)
async def post(self, inter: discord.Interaction, _btn: discord.ui.Button):
header = f"**Payout: {payout_name}** {title_emoji}"
settings = f"_Base {bw}× active % | Carrier +{cab} | Crawler +{crb}_"
if payout_type == "melange":
settings = f"_Refinery cut {lsrp}% | " + settings
txt = f"{header}\n{settings}\n{table}"
await inter.channel.send(txt)
await inter.response.edit_message(content="Posted to channel ✅", view=None)
@discord.ui.button(label="Back", style=discord.ButtonStyle.secondary)
async def back(self, inter: discord.Interaction, _btn: discord.ui.Button):
await self.outer.render_progress(inter, key)
content = f"**Preview** (not posted): **Payout: {payout_name}** {title_emoji}\n{table}"
try:
await interaction.response.edit_message(content=content, view=_PostView(self))
except discord.InteractionResponded:
await interaction.followup.send(content=content, ephemeral=True, view=_PostView(self))
# ------------------------ setup ------------------------
async def setup(bot: commands.Bot):
cog = SpicePayCog(bot)
await bot.add_cog(cog)

View File

View 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.",
"MuadDib walks the golden path.",
"Sandtrout seal the deserts 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.",
"MuadDib 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 sandworms 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 ones 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 bodys 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).")

View File

View 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)")

Some files were not shown because too many files have changed in this diff Show More