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
96 changed files with 7383 additions and 754 deletions

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

11
.gitignore vendored
View File

@ -9,4 +9,13 @@ venv/
data/
data.json
data.json.bak
settings*.conf
settings*.conf
NOTES.md
sanity/
.offline_data.json
dev/.env.production
dev/portainer_config.png
# Tools
wrapper/
wrapper/tools/

BIN
assets/docs/ShaiWatcher.png Normal file

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

261
bot.py
View File

@ -1,53 +1,43 @@
import os
import asyncio
import os, signal, asyncio, pathlib
import discord
from discord.ext import commands
from dotenv import load_dotenv
from configparser import ConfigParser
from data_manager import DataManager
import pathlib
import os, asyncio, xml.etree.ElementTree as ET
import aiohttp
from modules.common.settings import cfg as cfg_helper
from modules.common.boot_notice import post_boot_notice
VERSION="0.0.9"
# ---------- Env & config loading ----------
# Version consists of:
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.5.1.2.a4"
# ---------- Env loading ----------
load_dotenv()
TOKEN = os.getenv('DISCORD_TOKEN', '').strip()
CONFIG_PATH = os.getenv('SHAI_CONFIG', '/config/settings.conf')
def _get_env(name: str, default: str = "") -> str:
v = os.getenv(name, "")
return (v or "").strip().strip('"').strip("'") or default
config = ConfigParser()
read_files = config.read(CONFIG_PATH)
if not read_files:
print(f"[Config] INFO: no config at {CONFIG_PATH} (or unreadable). Will rely on env + defaults.")
TOKEN = _get_env("DISCORD_TOKEN")
DATA_FILE = _get_env("DATA_FILE") or "./data/data.json"
# Ensure DEFAULT section exists
if 'DEFAULT' not in config:
config['DEFAULT'] = {}
print("[Config] DISCORD_TOKEN set:", bool(TOKEN))
print("[Config] DATA_FILE:", DATA_FILE)
def _overlay_env_into_config(cfg: ConfigParser):
"""
Overlay all SHAI_* environment variables into cfg['DEFAULT'] so env wins.
Also accept SHAI_DATA_FILE or SHAI_DATA for data_file.
"""
d = cfg['DEFAULT']
# ---------- Ensure data path exists (fallback if not writable) ----------
data_dir = os.path.dirname(DATA_FILE) or "."
try:
os.makedirs(data_dir, exist_ok=True)
except PermissionError:
fallback = "./data/data.json"
print(f"[Config] No permission to create '{data_dir}'. Falling back to {fallback}")
DATA_FILE = fallback
data_dir = os.path.dirname(DATA_FILE)
os.makedirs(data_dir, exist_ok=True)
# Map SHAI_* -> lower-case keys (e.g. SHAI_MOD_CHANNEL_ID -> 'mod_channel_id')
for k, v in os.environ.items():
if not k.startswith('SHAI_'):
continue
key = k[5:].lower() # drop 'SHAI_' prefix
if key == 'data':
key = 'data_file'
d[key] = str(v)
if not d.get('data_file', '').strip():
d['data_file'] = '/data/data.json'
# Apply overlay so env takes precedence everywhere
_overlay_env_into_config(config)
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, "w", encoding="utf-8") as f:
f.write("{}")
# ---------- Discord intents ----------
@ -61,33 +51,56 @@ intents.voice_states = True
# ---------- Bot + DataManager ----------
data_file = config['DEFAULT']['data_file'] # guaranteed present by overlay
if not TOKEN:
print("[Config] WARNING: DISCORD_TOKEN not set (env). Bot will fail to log in.")
print("[Config] WARNING: DISCORD_TOKEN is empty. The bot will fail to log in.")
bot = commands.Bot(command_prefix='!', intents=intents)
bot.config = config
bot.data_manager = DataManager(data_file)
bot = commands.Bot(command_prefix="!", intents=intents)
# ---------- Self-check helpers ----------
# Ensure data path exists and is seeded
os.makedirs(os.path.dirname(DATA_FILE) or ".", exist_ok=True)
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, "w", encoding="utf-8") as f:
f.write("{}")
async def _guild_selfcheck(g: discord.Guild, cfg):
bot.data_manager = DataManager(DATA_FILE)
# ---------- Self-check: resolve from ENV first, then cfg_helper ----------
def _resolve_channel_id(c, key: str) -> int:
"""
Resolve channel IDs from the runtime settings store (cfg), with a final
fallback to legacy bot.config['DEFAULT'] if present. No SHAI_* env usage.
"""
try:
v = int(c.int(key, 0))
if v:
return v
except Exception:
pass
try:
# legacy DEFAULT mapping (ConfigParser-like or our shim)
v = int(getattr(c, "get", lambda *_: 0)(key, 0))
if v:
return v
except Exception:
pass
return 0
async def _guild_selfcheck(g: discord.Guild, c):
problems = []
def _need_channel(id_key, *perms):
raw = cfg.get(id_key)
if not raw:
cid = _resolve_channel_id(c, id_key)
if not cid:
problems.append(f"Missing config key: {id_key}")
return
try:
cid = int(raw)
except Exception:
problems.append(f"Bad channel id for {id_key}: {raw}")
return
ch = g.get_channel(cid)
if not ch:
problems.append(f"Channel not found: {id_key}={cid}")
return
me = g.me
p = ch.permissions_for(me)
for perm in perms:
@ -97,100 +110,100 @@ async def _guild_selfcheck(g: discord.Guild, cfg):
_need_channel('mod_channel_id', 'read_messages', 'send_messages', 'add_reactions', 'read_message_history')
_need_channel('modlog_channel_id', 'read_messages', 'send_messages')
_need_channel('pirates_list_channel_id', 'read_messages', 'send_messages')
_need_channel('dd_channel_id', 'read_messages', 'send_messages', 'read_message_history')
if problems:
print(f"[SelfCheck:{g.name}]")
for p in problems:
print(" -", p)
async def _fetch_latest_from_rss(url: str):
try:
timeout = aiohttp.ClientTimeout(total=8)
async with aiohttp.ClientSession(timeout=timeout) as sess:
async with sess.get(url) as resp:
if resp.status != 200:
return None, None
text = await resp.text()
# Gitea RSS structure: <rss><channel><item>…</item></channel></rss>
root = ET.fromstring(text)
item = root.find('./channel/item')
if item is None:
return None, None
title = (item.findtext('title') or '').strip()
link = (item.findtext('link') or '').strip()
# Try to extract short sha from link tail if it's a commit URL
sha = None
if '/commit/' in link:
sha = link.rsplit('/commit/', 1)[-1][:7]
# Many Gitea feeds put the commit subject in <title>
subject = title if title else None
return subject, sha
except Exception:
return None, None
# ---------- boot notice ----------
async def _post_boot_notice():
msg = f"Self-update and reboot successful! (v.{VERSION})"
ch_id_raw = bot.config['DEFAULT'].get('modlog_channel_id', '')
try:
ch_id = int(ch_id_raw) if ch_id_raw else 0
except Exception:
ch_id = 0
if not ch_id:
return
for g in bot.guilds:
ch = g.get_channel(ch_id)
if ch:
try:
await ch.send(msg)
except Exception:
pass
break
# ---------- events ----------
@bot.event
async def on_ready():
import asyncio
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
print("[Intents] members:", bot.intents.members, "/ message_content:", bot.intents.message_content, "/ voice_states:", bot.intents.voice_states)
print("[Intents] members:", bot.intents.members,
"/ message_content:", bot.intents.message_content,
"/ voice_states:", bot.intents.voice_states)
await asyncio.gather(*[_guild_selfcheck(g, bot.config['DEFAULT']) for g in bot.guilds])
env_cfg = cfg_helper(bot)
# Slash command sync
# Per-guild permission sanity checks (env-aware)
try:
dev_gid = bot.config['DEFAULT'].get('dev_guild_id')
if dev_gid:
guild = bot.get_guild(int(dev_gid))
if guild:
synced = await bot.tree.sync(guild=guild)
print(f"[Slash] Synced {len(synced)} commands to {guild.name}")
else:
synced = await bot.tree.sync()
print(f"[Slash] Synced {len(synced)} commands globally (dev_guild_id not in cache)")
await asyncio.gather(*[_guild_selfcheck(g, env_cfg) for g in bot.guilds])
except Exception as e:
print("[SelfCheck] failed:", repr(e))
# ---------- Slash command scope & sync ----------
try:
# env_cfg already exists above in on_ready()
gid = env_cfg.int("home_guild_id", 0)
if gid > 0:
print(f"[Slash] Mode: GUILD-ONLY → {gid}")
guild_obj = discord.Object(id=gid)
# Copy all currently-loaded global commands to HOME guild
bot.tree.copy_global_to(guild=guild_obj)
g_cmds = await bot.tree.sync(guild=guild_obj)
g_names = ", ".join(f"/{c.name}" for c in g_cmds) if g_cmds else "(none)"
print(f"[Slash] Synced {len(g_cmds)} commands to guild {gid}: {g_names}")
# Clear global so only guild-scoped remain
bot.tree.clear_commands(guild=None)
cleared = await bot.tree.sync() # push empty global set
print(f"[Slash] Cleared global commands (now {len(cleared)}).")
# Debug: list actual state after sync
try:
global_cmds = await bot.tree.fetch_commands()
print(f"[Slash] Global commands ({len(global_cmds)}): {', '.join(f'/{c.name}' for c in global_cmds) or '(none)'}")
except Exception as e:
print("[Slash] Failed to fetch global commands:", repr(e))
try:
g_cmds = await bot.tree.fetch_commands(guild=guild_obj)
print(f"[Slash] Guild {gid} commands ({len(g_cmds)}): {', '.join(f'/{c.name}' for c in g_cmds) or '(none)'}")
except Exception as e:
print(f"[Slash] Failed to fetch commands for guild {gid}:", repr(e))
else:
synced = await bot.tree.sync()
print(f"[Slash] Synced {len(synced)} commands globally")
print("[Slash] Mode: GLOBAL (HOME_GUILD_ID not set)")
global_cmds = await bot.tree.sync()
names = ", ".join(f"/{c.name}" for c in global_cmds) if global_cmds else "(none)"
print(f"[Slash] Synced {len(global_cmds)} commands globally: {names}")
except Exception as e:
print("[Slash] Sync failed:", repr(e))
# Boot notice in modlog
await _post_boot_notice()
# ---------- Auto-discover extensions ----------
modules_path = pathlib.Path(__file__).parent / 'modules'
modules_path = pathlib.Path(__file__).parent / "modules"
extensions = []
for folder in modules_path.iterdir():
if folder.is_dir():
for file in folder.glob('*.py'):
if file.name == '__init__.py':
continue
extensions.append(f"modules.{folder.name}.{file.stem}")
if not folder.is_dir():
continue
# skip non-cog helpers under modules/common
if folder.name == "common":
continue
for file in folder.glob("*.py"):
if file.name == "__init__.py":
continue
extensions.append(f"modules.{folder.name}.{file.stem}")
def _install_signal_handlers(loop, bot_obj):
def _graceful(*_):
loop.create_task(bot_obj.close())
for s in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(s, _graceful)
except NotImplementedError:
pass # Windows
async def main():
print(f"[STARTUP] ShaiWatcher booting v{VERSION}")
async with bot:
for ext in extensions:
try:
@ -198,7 +211,9 @@ async def main():
print(f"[Modules] Loaded: {ext}")
except Exception as e:
print(f"[Modules] Failed to load {ext}:", repr(e))
loop = asyncio.get_running_loop()
_install_signal_handlers(loop, bot)
await bot.start(TOKEN)
if __name__ == '__main__':
if __name__ == "__main__":
asyncio.run(main())

View File

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

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

@ -1,75 +0,0 @@
version: "3.8"
services:
shaiwatcher:
# Build the image from your repo (Portainer Git deploy will use this)
build:
context: .
dockerfile: dockerfile # <- your file is named 'dockerfile'
restart: unless-stopped
# Everything configurable shows up in Portainer's env UI
environment:
# --- Required token (set in Portainer UI) ---
DISCORD_TOKEN: ${DISCORD_TOKEN}
# --- Paths ---
SHAI_DATA_FILE: /data/data.json
# --- Reaction gating messages ---
SHAI_RULES_MESSAGE_ID: "1396831304460402738"
SHAI_ENGAGEMENT_MESSAGE_ID: "1397668657143742574"
SHAI_NICKNAME_MESSAGE_ID: "1403513532108247141"
# --- Roles ---
SHAI_RULES_ROLE_ID: "1403146506596253817"
SHAI_ENGAGEMENT_ROLE_ID: "1403146604894224458"
SHAI_FULL_ACCESS_ROLE_ID: "1403146645121667082"
SHAI_ADMIN_ROLE_ID: "1402000098476425246"
SHAI_FIELD_MOD_ROLE_ID: "1402001335041261681"
SHAI_INTEL_MOD_ROLE_ID: "1402001000327417946"
SHAI_MODERATOR_ROLE_ID: "1396828779015573598"
# --- Channels ---
SHAI_MOD_CHANNEL_ID: "1403139701522698240"
SHAI_MODLOG_CHANNEL_ID: "1403146993198436627"
SHAI_USERSLIST_CHANNEL_ID: "1403146908385542215"
SHAI_REPORT_CHANNEL_ID: "1403147077285843034"
SHAI_PIRATES_LIST_CHANNEL_ID: "1403147077285843034"
# --- Auto-VC ---
SHAI_TRIGGER_CHANNEL_ID: "1403139044174594190"
SHAI_AUTO_VC_CATEGORY_ID: "1403138882958266428"
SHAI_VC_NAME_PREFIX: "DD Crew " # trailing space intentional
SHAI_AUTO_VC_CLEANUP_DELAY: "30"
# --- Threat weights ---
SHAI_THREAT_W_KILL: "0.30"
SHAI_THREAT_W_DESTRUCTION: "0.40"
SHAI_THREAT_W_GROUP: "0.20"
SHAI_THREAT_W_SKILL: "0.10"
SHAI_THREAT_GROUP_THRESHOLD: "3"
SHAI_THREAT_MIN_SAMPLES_FOR_STATS: "3"
# --- Misc toggles ---
SHAI_RELEASE_VERSION: "false"
SHAI_NICK_NUDGE_LOOP_ENABLED: "false"
SHAI_HOME_GUILD_ID: "1396826999095427253"
SHAI_USER_CARDS_CRON_ENABLED: "true"
# --- SpicePay defaults ---
SHAI_SPICEPAY_LSR_CUT_PERCENT: "10"
SHAI_SPICEPAY_BASE_WEIGHT: "25"
SHAI_SPICEPAY_CARRIER_BONUS: "12.5"
SHAI_SPICEPAY_CRAWLER_BONUS: "12.5"
# --- Optional emojis (IDs) ---
SHAI_EMOJI_MELANGE_ID: "1401965356775510210"
SHAI_EMOJI_SAND_ID: "1401965308805255310"
SHAI_EMOJI_CARRIER_CRAWLER_ID: "1402285453037666386"
volumes:
- shaiwatcher_data:/data # persistent data.json lives here
volumes:
shaiwatcher_data:

View File

@ -1,30 +0,0 @@
#!/bin/sh
set -e
# Defaults (can be overridden)
: "${SHAI_CONFIG:=/config/settings.conf}"
: "${SHAI_DATA:=/data/data.json}"
# Seed /config/settings.conf on first run if it doesn't exist
if [ ! -f "$SHAI_CONFIG" ]; then
mkdir -p "$(dirname "$SHAI_CONFIG")"
if [ -f /app/example/settings.conf ]; then
cp /app/example/settings.conf "$SHAI_CONFIG"
echo "Seeded default settings to $SHAI_CONFIG"
else
# Fall back: generate minimal config so the app can boot
cat > "$SHAI_CONFIG" <<EOF
[DEFAULT]
data_file = ${SHAI_DATA}
EOF
echo "Generated minimal $SHAI_CONFIG"
fi
fi
# Ensure data directory exists
mkdir -p "$(dirname "$SHAI_DATA")"
# Make path visible to the app (bot.py will still read the INI)
export SHAI_CONFIG SHAI_DATA
exec "$@"

View File

@ -1,26 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# deps first
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# app code
COPY . /app
# runtime dirs + seed default config path (actual seeding done in entrypoint)
RUN mkdir -p /config /data
# runtime env defaults (can be overridden by compose/env)
ENV SHAI_CONFIG=/config/settings.conf \
SHAI_DATA=/data/data.json
# small, explicit entrypoint
COPY docker-entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint
ENTRYPOINT ["entrypoint"]
CMD ["python","-u","/app/bot.py"]

View File

@ -1,5 +0,0 @@
DISCORD_TOKEN={DISCORD_BOT_TOKEN}
DISCORD_APPLICATION_ID=
DISCORD_PUBLIC_KEY=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=

View File

@ -2,12 +2,15 @@
import re
import discord
from discord.ext import commands
from modules.common.settings import cfg # ENV-first config helper
def _parse_ids(raw: str):
ids = []
if not raw:
return ids
for tok in re.split(r'[,\s]+', raw.strip()):
if not tok:
continue
try:
ids.append(int(tok))
except Exception:
@ -15,20 +18,20 @@ def _parse_ids(raw: str):
return ids
def get_mod_role_ids(bot: commands.Bot):
cfg = bot.config['DEFAULT']
# read individually; allow comma-separated in any field for flexibility
# Read from ENV/INI via helper; allow comma-separated lists in any field
reader = cfg(bot)
keys = ["admin_role_id", "field_mod_role_id", "intel_mod_role_id", "moderator_role_id"]
ids = []
collected = []
for k in keys:
raw = cfg.get(k, "")
for tok in re.split(r"[,\s]+", raw.strip()):
if not tok:
continue
try:
ids.append(int(tok))
except Exception:
pass
return ids
collected.extend(_parse_ids(reader.get(k, "")))
# dedupe while preserving order
seen = set()
unique = []
for i in collected:
if i not in seen:
seen.add(i)
unique.append(i)
return unique
def is_moderator_member(member: discord.Member, bot: commands.Bot) -> bool:
if not isinstance(member, discord.Member):

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

@ -3,6 +3,9 @@ import asyncio
import time
import discord
from discord.ext import commands
from discord import app_commands
from modules.common.settings import cfg # ENV-first config helper
from mod_perms import require_mod_ctx, require_mod_interaction # <- use project mod perms
def now() -> float:
return time.time()
@ -17,25 +20,26 @@ class AutoVCCog(commands.Cog):
Admin commands:
/avc_status -> show current state
/avc_cleanup_now -> run a cleanup/renumber pass now
/avc_renumber -> renumber without deleting
/avc_cleanup_now -> [MOD] run a cleanup/renumber pass now
/avc_renumber -> [MOD] renumber without deleting
"""
def __init__(self, bot):
self.bot = bot
cfg = bot.config['DEFAULT']
r = cfg(bot)
# Config
self.trigger_id = int(cfg['trigger_channel_id'])
self.category_id = int(cfg['auto_vc_category_id'])
self.prefix = cfg['vc_name_prefix']
self.delay = int(cfg.get('auto_vc_cleanup_delay', 30))
self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0
# Config (ENV/INI via helper; safe defaults)
self.trigger_id = r.int('trigger_channel_id', 0)
self.category_id = r.int('auto_vc_category_id', 0)
self.prefix = r.get('vc_name_prefix', 'Room')
self.delay = r.int('auto_vc_cleanup_delay', 30)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
# State
self.empty_since: dict[int, float] = {} # channel_id -> ts when became empty
self.empty_since: dict[int, float] = {} # channel_id -> ts when became empty
self._vc_cooldowns: dict[int, float] = {} # user_id -> ts last created (anti-spam)
self._create_lock = asyncio.Lock()
self._ops_lock = asyncio.Lock() # serialize admin ops vs sweeper
# Background sweeper
self._task = asyncio.create_task(self._sweeper())
@ -92,6 +96,8 @@ class AutoVCCog(commands.Cog):
async def _cleanup_pass(self, guild: discord.Guild):
"""Delete empty tracked channels that exceeded delay and renumber."""
if not self.category_id:
return
cat = guild.get_channel(self.category_id)
if not cat:
return
@ -138,8 +144,10 @@ class AutoVCCog(commands.Cog):
await self.bot.wait_until_ready()
while not self.bot.is_closed():
try:
for guild in self.bot.guilds:
await self._cleanup_pass(guild)
# Serialize with admin ops
async with self._ops_lock:
for guild in self.bot.guilds:
await self._cleanup_pass(guild)
except Exception as e:
print("[auto_vc] sweeper loop error:", repr(e))
await asyncio.sleep(30)
@ -148,6 +156,9 @@ class AutoVCCog(commands.Cog):
async def _spawn_and_move(self, member: discord.Member):
guild = member.guild
if not self.category_id:
await self._log(guild, "⚠️ auto_vc_category_id not configured; cannot create rooms.")
return
cat = guild.get_channel(self.category_id)
if not cat:
await self._log(guild, "⚠️ auto_vc_category_id not found; cannot create rooms.")
@ -195,7 +206,7 @@ class AutoVCCog(commands.Cog):
guild = member.guild
# Create on trigger join (with 5s per-user cooldown)
if after.channel and after.channel.id == self.trigger_id:
if self.trigger_id and after.channel and after.channel.id == self.trigger_id:
last = self._vc_cooldowns.get(member.id, 0.0)
if now() - last < 5.0:
return
@ -206,7 +217,7 @@ class AutoVCCog(commands.Cog):
print("[auto_vc] spawn/move failed:", repr(e))
# Mark empties immediately on leave
if before.channel:
if before.channel and self.category_id:
ch = before.channel
if ch.category_id == self.category_id:
rec = self._find_record(guild.id, ch.id)
@ -215,13 +226,15 @@ class AutoVCCog(commands.Cog):
# ------------- admin commands -------------
@commands.hybrid_command(name="avc_status", description="Show Auto-VC status for this guild")
@commands.has_permissions(manage_guild=True)
async def avc_status(self, ctx: commands.Context):
g = ctx.guild
@app_commands.command(name="avc_status", description="Show Auto-VC status for this guild")
async def avc_status(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
g = interaction.guild
recs = sorted(self._vc_records(g.id), key=lambda r: r.get('created_ts', 0))
lines = [
f"Trigger: <#{self.trigger_id}> | Category: <#{self.category_id}> | Prefix: `{self.prefix}` | Delay: {self.delay}s"
f"Trigger: <#{self.trigger_id or 0}> | Category: <#{self.category_id or 0}> | Prefix: `{self.prefix}` | Delay: {self.delay}s"
]
for idx, rec in enumerate(recs, start=1):
ch = g.get_channel(rec['channel_id'])
@ -234,21 +247,35 @@ class AutoVCCog(commands.Cog):
t = self.empty_since.get(rec['channel_id'])
tail = f" | idle {int(now()-t)}s" if t and (not ch or (ch and not ch.members)) else ""
lines.append(f"- #{idx}: {name}{state}{tail}")
msg = "Auto-VC status:\n" + "\n".join(lines) if lines else "No Auto-VC rooms tracked."
await ctx.reply(msg)
await interaction.response.send_message(msg)
@commands.hybrid_command(name="avc_cleanup_now", description="Run an immediate cleanup pass (delete idle rooms & renumber)")
@commands.has_permissions(manage_guild=True)
async def avc_cleanup_now(self, ctx: commands.Context):
await self._cleanup_pass(ctx.guild)
await ctx.reply("Cleanup pass complete.")
@app_commands.command(name="avc_cleanup_now", description="[MOD] Run an immediate cleanup pass (delete idle rooms & renumber)")
async def avc_cleanup_now(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
@commands.hybrid_command(name="avc_renumber", description="Force a renumber of tracked rooms")
@commands.has_permissions(manage_guild=True)
async def avc_renumber(self, ctx: commands.Context):
await self._renumber(ctx.guild)
await ctx.reply("Renumbered.")
await interaction.response.defer(ephemeral=True)
async with self._ops_lock:
await self._cleanup_pass(interaction.guild)
await self._log(interaction.guild, f"🧹 Cleanup pass invoked by {interaction.user.mention}")
await interaction.followup.send("Cleanup pass complete.", ephemeral=True)
@app_commands.command(name="avc_renumber", description="[MOD] Force a renumber of tracked rooms")
async def avc_renumber(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
await interaction.response.defer(ephemeral=True)
async with self._ops_lock:
await self._renumber(interaction.guild)
await self._log(interaction.guild, f"🔢 Renumber invoked by {interaction.user.mention}")
await interaction.followup.send("Renumbered.", ephemeral=True)
async def setup(bot):
await bot.add_cog(AutoVCCog(bot))

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

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

@ -1,45 +1,56 @@
# modules/nick_nudge/nick_nudge.py
import asyncio
import time
from typing import Optional, Tuple
from collections import defaultdict
import discord
from discord.ext import commands
from discord import app_commands
from mod_perms import is_moderator_userid
from mod_perms import is_moderator_userid, require_mod_interaction
from modules.common.emoji_accept import is_accept
from modules.common.settings import cfg # ENV-first config helper
CHECK = '' # approved/verified
CROSS = '' # reject / no
PENDING = '✔️' # heavy check mark = pending claim
ACCEPT = {CHECK, '🫡'}
ACCEPT = {CHECK, '🫡'}
NO_MENTIONS = discord.AllowedMentions.none()
# Per-user in-process lock to prevent duplicate reviews from concurrent reactions
_user_locks = defaultdict(asyncio.Lock)
def _ts_rel(ts: Optional[float] = None) -> str:
"""Discord relative timestamp like <t:12345:R>."""
if ts is None:
ts = time.time()
return f"<t:{int(ts)}:R>"
class NickNudgeCog(commands.Cog):
"""
Handles:
DM nickname nudge loop (optional; unchanged behavior)
Nickname *review* workflow for claims:
- On claim (via reaction or /nick_same): create a mod review in mod_channel with /
- Mods react: -> mark verified; -> clear claim and revoke Full Access
Stores review mapping in data_manager['nick_reviews']
Nickname review flow:
- Atomic transition to 'pending' and open exactly ONE review.
- Mods: approve -> mark verified; reject -> clear claim.
- If a verified user changes their nickname, verification is revoked automatically.
Data keys used in data_manager:
agreed_nickname: [user_id]
nick_claim_pending: [user_id]
nick_verified: [user_id]
nick_reviews: [{ message_id, guild_id, user_id, status, ... }]
nick_verified_name: [{ guild_id, user_id, nick, ts }]
"""
def __init__(self, bot):
self.bot = bot
cfg = bot.config['DEFAULT']
self.modlog_channel_id = int(cfg['modlog_channel_id'])
self.mod_channel_id = int(cfg['mod_channel_id']) # same review channel as pirate reports
# Optional DM nudge loop retained
try:
self.loop_enabled = cfg.getboolean('nick_nudge_loop_enabled')
except Exception:
self.loop_enabled = False
r = cfg(bot)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
self.mod_channel_id = r.int('mod_channel_id', 0)
self.loop_enabled = r.bool('nick_nudge_loop_enabled', False)
self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None
# ---------- utils ----------
@ -52,6 +63,8 @@ class NickNudgeCog(commands.Cog):
pass
async def _modlog(self, guild: discord.Guild, content: str):
if not self.modlog_channel_id:
return
ch = guild.get_channel(self.modlog_channel_id)
if ch:
try:
@ -61,10 +74,6 @@ class NickNudgeCog(commands.Cog):
self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content})
async def _find_last_nick_change(self, guild: discord.Guild, member: discord.Member) -> Tuple[Optional[str], Optional[str]]:
"""
Best-effort: look up last nickname change via audit logs.
Returns (before_nick, after_nick) or (None, None) if not found/allowed.
"""
try:
async for entry in guild.audit_logs(limit=10, action=discord.AuditLogAction.member_update):
if entry.target.id != member.id or not entry.changes:
@ -77,24 +86,60 @@ class NickNudgeCog(commands.Cog):
pass
return None, None
# ---------- public API (called by ReactionRole cog) ----------
# ---------- atomic entry point used by all claim sources ----------
async def ensure_pending_and_maybe_open(self, guild: discord.Guild, member: discord.Member, source: str):
"""
Atomically:
- set pending (idempotent)
- open ONE review if this is the first transition to pending
Prevents duplicate reviews when multiple reactions/commands fire.
"""
if not guild or not self.mod_channel_id or member.bot:
return
newly_pending = False
lock = _user_locks[member.id]
async with lock:
dm = self.bot.data_manager
# If a pending review already exists, bail out
for r in dm.get('nick_reviews'):
if r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending':
return
# Mark "agreed" and flip to pending if not already
if member.id not in dm.get('agreed_nickname'):
dm.add('agreed_nickname', int(member.id))
dm.remove('nick_verified', lambda x: x == member.id)
if member.id not in dm.get('nick_claim_pending'):
dm.add('nick_claim_pending', int(member.id))
newly_pending = True
if newly_pending:
try:
await self.start_nick_review(guild, member, source=source)
except Exception:
# Roll back pending on failure so the user can try again
try:
dm = self.bot.data_manager
dm.remove('nick_claim_pending', lambda x: x == member.id)
except Exception:
pass
# ---------- public API ----------
async def start_nick_review(self, guild: discord.Guild, member: discord.Member, source: str = "claim"):
"""
Create (or update) a nickname review entry for this member in the mod channel.
- source: "claim" or "nick_same"
Stores in data_manager['nick_reviews'] a record keyed by the review message_id.
"""
if not guild:
if not guild or not self.mod_channel_id:
return
mod_ch = guild.get_channel(self.mod_channel_id)
if not mod_ch:
return
before_n, after_n = await self._find_last_nick_change(guild, member)
before_n, _ = await self._find_last_nick_change(guild, member)
now_ts = int(time.time())
# Compose review text
title = "📝 **Nickname Verification Request**"
who = f"User: {member.mention} (`{member.id}`)"
change = f"Claimed {_ts_rel(now_ts)}"
@ -110,7 +155,6 @@ class NickNudgeCog(commands.Cog):
except Exception:
return
# Persist review mapping
self.bot.data_manager.add('nick_reviews', {
'message_id': int(msg.id),
'guild_id': int(guild.id),
@ -122,23 +166,21 @@ class NickNudgeCog(commands.Cog):
'ts': now_ts
})
# Log to modlog
await self._modlog(guild, f"🔎 Nickname review opened for {member.mention}{method}{_ts_rel(now_ts)}.")
# ---------- DM nudge loop (unchanged) ----------
# ---------- DM nudge loop ----------
async def _nudge_loop(self):
await self.bot.wait_until_ready()
while not self.bot.is_closed():
try:
now = time.time()
now_t = time.time()
for guild in self.bot.guilds:
for member in guild.members:
if member.bot or not member.joined_at:
continue
if (now - member.joined_at.timestamp()) < 24*3600:
if (now_t - member.joined_at.timestamp()) < 24 * 3600:
continue
# If they already have a server nick OR already claimed/verified, skip nudging
dm = self.bot.data_manager
if (member.nick and member.nick.strip()):
continue
@ -158,7 +200,7 @@ class NickNudgeCog(commands.Cog):
'message_id': int(msg.id),
'user_id': int(member.id),
'guild_id': int(guild.id),
'ts': now
'ts': now_t
})
self.bot.data_manager.add('nick_nudged', int(member.id))
await self._modlog(guild, f"📨 Sent nickname nudge to {member.mention}")
@ -166,10 +208,52 @@ class NickNudgeCog(commands.Cog):
pass
except Exception:
pass
await asyncio.sleep(1800) # every 30 minutes
await asyncio.sleep(1800)
# ---------- listeners ----------
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member):
"""Revoke verification if a verified user changes their nick to anything."""
if before.bot or before.guild != after.guild:
return
if before.nick == after.nick:
return
dm = self.bot.data_manager
# Only act if the user is currently verified
if before.id in dm.get('nick_verified'):
dm.remove('nick_verified', lambda x: x == before.id)
dm.remove('nick_claim_pending', lambda x: x == before.id)
try:
dm.remove('nick_verified_name', lambda r: r.get('guild_id') == before.guild.id and r.get('user_id') == before.id)
except Exception:
pass
dm.add('nick_verified_name', {
'guild_id': int(before.guild.id),
'user_id': int(before.id),
'nick': after.nick if after.nick else None,
'ts': int(time.time())
})
try:
await self._modlog(after.guild, f"⚠️ {after.mention} changed nickname; **verification revoked**. They must re-claim for a new review.")
except Exception:
pass
rr = self.bot.get_cog('ReactionRoleCog')
if rr:
try:
await rr.maybe_apply_full_access(after)
except Exception:
pass
cards = self.bot.get_cog('UserCardsCog')
if cards:
try:
await cards.refresh_card(after)
except Exception:
pass
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
# 1) Handle DM nudge confirmations (user reacts with an accept in DM)
@ -181,26 +265,14 @@ class NickNudgeCog(commands.Cog):
member = guild.get_member(entry['user_id']) if guild else None
if not member:
return
# Treat as a claim: mark pending (idempotent) and open review only on first transition
dm = self.bot.data_manager
if member.id not in dm.get('agreed_nickname'):
dm.add('agreed_nickname', int(member.id))
dm.remove('nick_verified', lambda x: x == member.id)
newly_pending = False
if member.id not in dm.get('nick_claim_pending'):
dm.add('nick_claim_pending', int(member.id))
newly_pending = True
if newly_pending:
try:
await self.start_nick_review(guild, member, source="nick_same")
except Exception:
pass
try:
await self.ensure_pending_and_maybe_open(guild, member, source="nick_same")
except Exception:
pass
# Clean map entry
self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id)
# Refresh card and maybe full access (pending does NOT block full access)
rr = self.bot.get_cog('ReactionRoleCog')
if rr:
try:
@ -217,16 +289,14 @@ class NickNudgeCog(commands.Cog):
# 2) Handle moderator review reactions in mod channel
if payload.guild_id and str(payload.emoji) in (CHECK, CROSS) and payload.user_id != self.bot.user.id:
if payload.channel_id != self.mod_channel_id:
if payload.channel_id != self.mod_channel_id or not self.mod_channel_id:
return
guild = self.bot.get_guild(payload.guild_id)
if not guild:
return
# Only moderators can act
if not is_moderator_userid(guild, payload.user_id, self.bot):
return
# Is this a review message?
reviews = self.bot.data_manager.get('nick_reviews')
review = next((r for r in reviews if r.get('message_id') == payload.message_id and r.get('guild_id') == guild.id), None)
if not review or review.get('status') != 'pending':
@ -234,11 +304,9 @@ class NickNudgeCog(commands.Cog):
member = guild.get_member(int(review['user_id']))
if not member:
# mark closed missing
self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status':'closed_missing'}), r)[1])
self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'closed_missing'}), r)[1])
return
# Fetch and edit the review message content (best-effort)
try:
ch = self.bot.get_channel(payload.channel_id)
msg = await ch.fetch_message(payload.message_id)
@ -250,17 +318,25 @@ class NickNudgeCog(commands.Cog):
approver = f"<@{payload.user_id}>"
if str(payload.emoji) == CHECK:
# Approve: mark verified, clear pending, ensure agreed flag set
if member.id not in dm.get('agreed_nickname'):
dm.add('agreed_nickname', int(member.id))
dm.remove('nick_claim_pending', lambda x: x == member.id)
if member.id not in dm.get('nick_verified'):
dm.add('nick_verified', int(member.id))
# Update review record
try:
dm.remove('nick_verified_name', lambda r: r.get('guild_id') == guild.id and r.get('user_id') == member.id)
except Exception:
pass
dm.add('nick_verified_name', {
'guild_id': int(guild.id),
'user_id': int(member.id),
'nick': member.nick if member.nick else None,
'ts': now_ts
})
dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'approved', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
# Edit the review message
if msg:
try:
await msg.clear_reactions()
@ -271,12 +347,8 @@ class NickNudgeCog(commands.Cog):
except Exception:
pass
# Modlog
await self._modlog(guild,
f"✅ Nickname **verified** for {member.mention} by {approver}{_ts_rel(now_ts)}."
)
await self._modlog(guild, f"✅ Nickname **verified** for {member.mention} by {approver}{_ts_rel(now_ts)}.")
# Refresh roles / card
rr = self.bot.get_cog('ReactionRoleCog')
if rr:
try:
@ -291,12 +363,10 @@ class NickNudgeCog(commands.Cog):
pass
else:
# Reject: clear all nickname flags; Full Access should be revoked by maybe_apply_full_access
dm.remove('agreed_nickname', lambda x: x == member.id)
dm.remove('nick_claim_pending', lambda x: x == member.id)
dm.remove('nick_verified', lambda x: x == member.id)
# Update review record
dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'rejected', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
if msg:
@ -309,11 +379,8 @@ class NickNudgeCog(commands.Cog):
except Exception:
pass
await self._modlog(guild,
f"❌ Nickname **rejected** for {member.mention} by {approver}{_ts_rel(now_ts)}."
)
await self._modlog(guild, f"❌ Nickname **rejected** for {member.mention} by {approver}{_ts_rel(now_ts)}.")
# Refresh roles / card
rr = self.bot.get_cog('ReactionRoleCog')
if rr:
try:
@ -327,5 +394,140 @@ class NickNudgeCog(commands.Cog):
except Exception:
pass
# ---------- Mod commands to manipulate nickname reviews ----------
@app_commands.command(name="clear_nick_reviews", description="[MOD] Delete all PENDING nickname review records for this server.")
async def clear_nick_reviews(self, interaction: discord.Interaction):
"""Moderator-only. Clears all 'pending' entries in data_manager['nick_reviews'] for this guild."""
# Must be used in a guild
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
# Moderator permission check (your existing gate)
if not await require_mod_interaction(interaction):
return # require_mod_interaction already responded
dm = self.bot.data_manager
# Count pending records for this guild
pending = [
r for r in dm.get('nick_reviews')
if r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending'
]
count = len(pending)
# Remove pending records
if count:
dm.remove(
'nick_reviews',
lambda r: r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending'
)
# Modlog + ephemeral confirmation
try:
await self._modlog(
interaction.guild,
f"🧹 {interaction.user.mention} cleared **{count}** pending nickname review(s)."
)
except Exception:
pass
await interaction.response.send_message(
f"Cleared **{count}** pending nickname review{'s' if count != 1 else ''}.",
ephemeral=True
)
@app_commands.command(name="recreate_nick_reviews", description="[MOD] Scan and recreate any missing pending nickname reviews for this server.")
async def recreate_nick_reviews(self, interaction: discord.Interaction):
"""Moderator-only bulk fixer for 'grey checkmark' users (claimed but no pending review)."""
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction):
return # already replied
dm = self.bot.data_manager
guild = interaction.guild
agreed = set(dm.get('agreed_nickname'))
verified = set(dm.get('nick_verified'))
# Build a quick lookup of existing pending reviews
pending_reviews = {
(r.get('guild_id'), r.get('user_id'))
for r in dm.get('nick_reviews')
if r.get('status') == 'pending'
}
to_fix = []
for uid in agreed:
# Needs a review if not verified and no pending review exists
if (guild.id, uid) not in pending_reviews and uid not in verified:
m = guild.get_member(uid)
if m and not m.bot:
to_fix.append(m)
fixed = 0
skipped = 0
for member in to_fix:
try:
# Clear stale pending so the atomic method will transition and open a new one
dm.remove('nick_claim_pending', lambda x, _uid=member.id: x == _uid)
await self.ensure_pending_and_maybe_open(guild, member, source="recreate")
fixed += 1
except Exception:
skipped += 1
try:
await self._modlog(guild, f"🛠️ {interaction.user.mention} recreated **{fixed}** nickname review(s); skipped **{skipped}**.")
except Exception:
pass
await interaction.response.send_message(
f"Recreated **{fixed}** review(s); skipped **{skipped}**.",
ephemeral=True
)
@app_commands.command(name="recreate_nick_review", description="[MOD] Recreate a missing pending nickname review for one user.")
@app_commands.describe(user="Member to recreate review for")
async def recreate_nick_review(self, interaction: discord.Interaction, user: discord.Member):
"""Moderator-only single-user fixer."""
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction):
return # already replied
guild = interaction.guild
dm = self.bot.data_manager
# If already verified, nothing to do
if user.id in dm.get('nick_verified'):
return await interaction.response.send_message("User is already verified — no review needed.", ephemeral=True)
# If a pending review already exists, nothing to do
has_pending = any(
r.get('guild_id') == guild.id and r.get('user_id') == user.id and r.get('status') == 'pending'
for r in dm.get('nick_reviews')
)
if has_pending:
return await interaction.response.send_message("A pending review already exists for this user.", ephemeral=True)
# If they never agreed/claimed, mark claim now so the state is consistent
if user.id not in dm.get('agreed_nickname'):
dm.add('agreed_nickname', int(user.id))
# Clear stale pending flag, then open atomically
dm.remove('nick_claim_pending', lambda x: x == user.id)
try:
await self.ensure_pending_and_maybe_open(guild, user, source="recreate")
except Exception:
return await interaction.response.send_message("Failed to create the review (see logs).", ephemeral=True)
try:
await self._modlog(guild, f"🛠️ {interaction.user.mention} recreated a nickname review for {user.mention}.")
except Exception:
pass
await interaction.response.send_message("Recreated the nickname review for that user.", ephemeral=True)
async def setup(bot):
await bot.add_cog(NickNudgeCog(bot))

View File

@ -2,25 +2,24 @@
import asyncio
import discord
from discord.ext import commands
from discord import app_commands
from datetime import datetime
from mod_perms import require_mod_ctx # use your configured moderator roles
from mod_perms import require_mod_ctx, require_mod_interaction
from modules.common.settings import cfg # ENV-first config helper
class PirateCardsCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
cfg = bot.config['DEFAULT']
self.pirates_channel_id = int(cfg['pirates_list_channel_id'])
self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0
r = cfg(bot)
# thresholds / samples (optional, with defaults)
try:
self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
except Exception:
self.group_threshold = 3
try:
self.min_samples = int(cfg.get('threat_min_samples_for_stats', '3'))
except Exception:
self.min_samples = 3
# IDs / config (ENV -> optional INI fallback)
self.pirates_channel_id = r.int('pirates_list_channel_id', 0)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
# thresholds / samples (with defaults)
self.group_threshold = r.int('threat_group_threshold', 3)
self.min_samples = r.int('threat_min_samples_for_stats', 3)
# safe posting (dont ping)
self._no_mentions = discord.AllowedMentions.none()
@ -97,7 +96,7 @@ class PirateCardsCog(commands.Cog):
async def _build_embed(self, pirate: dict) -> discord.Embed:
encs = self._encounters_for(pirate)
total = len(encs)
# guard numeric fields
def _i(v, d=0):
try:
return int(v)
@ -198,29 +197,28 @@ class PirateCardsCog(commands.Cog):
await self.refresh_card_for_account(guild, new_account)
# -------- command (mod-gated via require_mod_ctx) --------
@commands.hybrid_command(name="pirate_cards_rebuild", description="Rebuild pirate cards for all known pirates")
async def pirate_cards_rebuild(self, ctx: commands.Context):
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
@app_commands.command(name="pirate_cards_rebuild", description="[MOD] Rebuild pirate cards for all known pirates")
async def pirate_cards_rebuild(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
if not ctx.guild:
return await ctx.reply("Use this in a server.", ephemeral=True)
async with self._lock_for(ctx.guild.id):
guild = ctx.guild
ch = guild.get_channel(self.pirates_channel_id)
if not ch:
return await ctx.reply("Configured pirates_list_channel_id not found.", ephemeral=True)
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
ch = guild.get_channel(self.pirates_channel_id)
if not ch:
return await interaction.followup.send("Configured pirates_list_channel_id not found.", ephemeral=True)
count = 0
for p in self.bot.data_manager.get('pirates'):
try:
await self.refresh_card_for_account(guild, p.get('account_name', ''))
count += 1
except Exception:
continue
count = 0
for p in self.bot.data_manager.get('pirates'):
try:
await self.refresh_card_for_account(guild, p.get('account_name', ''))
count += 1
except Exception:
continue
is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash)
await interaction.followup.send(f"Rebuilt/updated {count} pirate cards.", ephemeral=True)
async def setup(bot):
await bot.add_cog(PirateCardsCog(bot))

View File

@ -1,9 +1,12 @@
# modules/pirate_report/pirate_report.py
import re
import time
from datetime import datetime, timezone
from urllib.parse import urlparse
import discord
from discord.ext import commands
from discord import app_commands
from modules.common.settings import cfg
from mod_perms import (
is_moderator_member,
@ -19,12 +22,20 @@ CROSS = '❌'
_ACCT_RE = re.compile(r'.+#\d{5}$')
DISCORD_MEDIA_HOSTS = {"cdn.discordapp.com", "media.discordapp.net"}
MEDIA_EXTS_IMAGE = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
MEDIA_EXTS_VIDEO = {".mp4", ".webm", ".mov"}
MEDIA_EXTS_ALL = MEDIA_EXTS_IMAGE | MEDIA_EXTS_VIDEO
def _acct_ok(s: str) -> bool:
return bool(_ACCT_RE.fullmatch(s.strip()))
def _now_utc_str() -> str:
return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
def _parse_bool(s: str) -> bool:
v = s.strip().lower()
if v in ('y', 'yes', 'true', 't', '1'):
@ -33,6 +44,81 @@ def _parse_bool(s: str) -> bool:
return False
raise ValueError("Please enter yes or no")
def _classify_discord_media(url: str):
"""
Return ('image'|'video', normalized_url) if valid Discord CDN media; else (None, reason).
We only accept direct CDN links so media can render inline without leaving the channel.
"""
try:
u = url.strip()
if not u:
return (None, "Empty URL.")
pr = urlparse(u)
if pr.scheme not in ("http", "https"):
return (None, "URL must start with http:// or https://")
host = pr.netloc.lower()
if host not in DISCORD_MEDIA_HOSTS:
return (None, "URL must be a **Discord media** link (cdn.discordapp.com or media.discordapp.net).")
path = pr.path or ""
dot = path.rfind(".")
if dot == -1:
return (None, "URL must end with a known media file extension.")
ext = path[dot:].lower()
if ext not in MEDIA_EXTS_ALL:
return (None, f"Unsupported media type `{ext}`. Allowed: images {sorted(MEDIA_EXTS_IMAGE)}, videos {sorted(MEDIA_EXTS_VIDEO)}.")
kind = "image" if ext in MEDIA_EXTS_IMAGE else "video"
return (kind, u)
except Exception:
return (None, "Invalid URL format.")
def _jump_url(guild_id: int, channel_id: int, message_id: int) -> str:
return f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}"
def _make_report_embed(title: str, color: discord.Color, report_dict: dict, include_status: bool = False):
e = discord.Embed(title=title, color=color, timestamp=datetime.utcnow())
e.add_field(name="Character", value=report_dict['character_name'], inline=False)
e.add_field(name="Account", value=report_dict['account_name'], inline=False)
e.add_field(name="Submitted by", value=f"<@{report_dict['submitter_id']}>", inline=False)
proof_url = (report_dict.get('proof_url') or "").strip()
proof_type = (report_dict.get('proof_type') or "").strip()
if include_status:
e.add_field(
name="Status",
value=report_dict.get('status_line', 'Pending'),
inline=False
)
# Show proof inline if it's an image; for videos we'll keep the URL in message content.
if proof_url and proof_type == 'image':
e.set_image(url=proof_url)
# Add a compact proof field for quick visibility (always safe to include)
e.add_field(
name="Proof",
value=proof_url if proof_url else "_No proof provided — strongly encouraged._",
inline=False
)
# A small hint footer
e.set_footer(text="Attach proof when possible to speed up moderation.")
return e
# --------------------- Views ----------------------
class ReportJumpView(discord.ui.View):
"""Simple link button to jump to the user's original ack message."""
def __init__(self, url: str):
super().__init__(timeout=None)
if url:
self.add_item(discord.ui.Button(label="Jump to message", style=discord.ButtonStyle.link, url=url))
# --------------------- Modals ----------------------
class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
@ -52,9 +138,16 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
max_length=64,
required=True
)
self.proof_url = discord.ui.TextInput(
label="Proof (Discord media URL — optional)",
placeholder="Direct Discord CDN link to image/video (highly encouraged, but optional)",
required=False,
max_length=300
)
self.add_item(self.character_name)
self.add_item(self.account_name)
self.add_item(self.proof_url)
async def on_submit(self, interaction: discord.Interaction):
if not interaction.guild:
@ -62,6 +155,7 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
char = self.character_name.value.strip()
acct = self.account_name.value.strip()
proof_raw = (self.proof_url.value or "").strip()
if not _acct_ok(acct):
return await interaction.response.send_message(
@ -69,6 +163,14 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
ephemeral=True
)
proof_type = ""
proof_val = ""
if proof_raw:
pt, pv = _classify_discord_media(proof_raw)
if pt is None:
return await interaction.response.send_message(f"❌ Invalid proof link: {pv}", ephemeral=True)
proof_type, proof_val = pt, pv # valid
dm = self.cog.bot.data_manager
char_l = char.lower()
acct_l = acct.lower()
@ -95,18 +197,33 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
print("[pirate_report] ack send failed:", repr(e))
ack = None
# Send to mod channel with ✅/❌
# Send to mod channel with ✅/❌, including inline media if present
mod_ch = interaction.guild.get_channel(self.cog.mod_channel)
if not mod_ch:
return await interaction.response.send_message("❌ Mod channel not configured.", ephemeral=True)
report_payload = {
'character_name': char,
'account_name': acct,
'submitter_id': interaction.user.id,
'proof_url': proof_val,
'proof_type': proof_type,
}
# Build jump button (if ack exists)
view = None
if ack:
try:
url = _jump_url(interaction.guild.id, interaction.channel.id, ack.id)
view = ReportJumpView(url)
except Exception:
view = None
try:
mod_msg = await mod_ch.send(
f"🚩 **Pirate Report**\n"
f"**Character:** {char}\n"
f"**Account:** {acct}\n"
f"**Submitted by:** {interaction.user.mention}"
)
embed = _make_report_embed("🚩 Pirate Report", discord.Color.orange(), report_payload, include_status=False)
# Video must be in message content for inline player; images live in the embed
content = proof_val if proof_type == 'video' else None
mod_msg = await mod_ch.send(content=content, embed=embed, view=view)
await mod_msg.add_reaction(CHECK)
await mod_msg.add_reaction(CROSS)
except Exception as e:
@ -122,11 +239,14 @@ class ReportModal(discord.ui.Modal, title="Submit Pirate Report"):
'origin_channel_id': interaction.channel.id if interaction.channel else 0,
'ack_message_id': ack.id if ack else 0,
'status': 'pending',
'ts': now
'ts': now,
'proof_url': proof_val,
'proof_type': proof_type,
})
await interaction.response.send_message("✅ Report submitted to moderators.", ephemeral=True)
class EditPirateModal(discord.ui.Modal, title="Edit Pirate Entry"):
def __init__(self, cog: "PirateReportCog"):
super().__init__()
@ -201,6 +321,7 @@ class EditPirateModal(discord.ui.Modal, title="Edit Pirate Entry"):
await interaction.response.send_message("✅ Pirate updated.", ephemeral=True)
await self.cog._refresh_pirates_list(interaction.guild)
class EncounterModal(discord.ui.Modal, title="Log Pirate Encounter"):
def __init__(self, cog: "PirateReportCog"):
super().__init__()
@ -336,25 +457,31 @@ class EncounterModal(discord.ui.Modal, title="Log Pirate Encounter"):
await self.cog._refresh_pirates_list(interaction.guild)
# -------------- Cog: commands + listeners ---------------
class PirateReportCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
cfg = bot.config['DEFAULT']
self.mod_channel = int(cfg['mod_channel_id'])
self.modlog_channel_id = int(cfg['modlog_channel_id'])
r = cfg(bot)
# Optional threat weights (normalized elsewhere if you added them)
try:
self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
except Exception:
self.group_threshold = 3
# Defaults if not already present in your earlier version:
self.w_kill = float(cfg.get('threat_w_kill', '0.35'))
self.w_destruction = float(cfg.get('threat_w_destruction', '0.30'))
self.w_group = float(cfg.get('threat_w_group', '0.20'))
self.w_skill = float(cfg.get('threat_w_skill', '0.15'))
# Channels
self.mod_channel = r.int('mod_channel_id', 0)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
# Threat config
self.group_threshold = r.int('threat_group_threshold', 3)
def _f(key: str, default: float) -> float:
try:
return float(r.get(key, str(default)))
except Exception:
return default
self.w_kill = _f('threat_w_kill', 0.35)
self.w_destruction = _f('threat_w_destruction', 0.30)
self.w_group = _f('threat_w_group', 0.20)
self.w_skill = _f('threat_w_skill', 0.15)
async def _refresh_pirates_list(self, guild: discord.Guild):
plist = self.bot.get_cog('PiratesListCog')
@ -362,6 +489,8 @@ class PirateReportCog(commands.Cog):
await plist.refresh_list(guild)
async def _modlog(self, guild: discord.Guild, content: str):
if not self.modlog_channel_id:
return
ch = guild.get_channel(self.modlog_channel_id)
if ch:
try:
@ -389,18 +518,23 @@ class PirateReportCog(commands.Cog):
return (matches[0], None)
# Remove pirate (mod-only)
@commands.hybrid_command(name='remove_pirate', description='Remove an approved pirate entry')
async def remove_pirate(self, ctx, account_name: str):
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
@app_commands.command(name="remove_pirate", description="[MOD] Remove an approved pirate entry")
@app_commands.describe(account_name="Account name to remove")
async def remove_pirate(self, interaction: discord.Interaction, account_name: str):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
acct_lower = account_name.strip().lower()
dm = self.bot.data_manager
if not any(p['account_name'].lower() == acct_lower for p in dm.get('pirates')):
return await ctx.reply("Pirate not found.")
return await interaction.response.send_message("Pirate not found.", ephemeral=True)
dm.remove('pirates', lambda p: p['account_name'].lower() == acct_lower)
await self._modlog(ctx.guild, f"🗑️ Removed pirate {account_name} by {ctx.author.mention}")
await self._refresh_pirates_list(ctx.guild)
await ctx.reply("Removed.", ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None)
await self._modlog(interaction.guild, f"🗑️ Removed pirate {account_name} by {interaction.user.mention}")
await self._refresh_pirates_list(interaction.guild)
await interaction.response.send_message("Removed.", ephemeral=True)
# Modal launchers
@app_commands.command(name="report", description="Submit a pirate report for moderator review (opens a form)")
@ -409,7 +543,7 @@ class PirateReportCog(commands.Cog):
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
await interaction.response.send_modal(ReportModal(self))
@app_commands.command(name="edit_pirate", description="Edit a pirate entry (opens a form)")
@app_commands.command(name="edit_pirate", description="[MOD] Edit a pirate entry (opens a form)")
async def edit_pirate(self, interaction: discord.Interaction):
if not await require_mod_interaction(interaction):
return
@ -422,10 +556,15 @@ class PirateReportCog(commands.Cog):
await interaction.response.send_modal(EncounterModal(self))
# ---- Migration: convert encounter identifiers to accounts (mod-only) ----
@commands.hybrid_command(name='encounters_migrate_ids', description='Migrate encounter identifiers to account names')
async def encounters_migrate_ids(self, ctx: commands.Context):
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
@app_commands.command(name="encounters_migrate_ids", description="[MOD] Migrate encounter identifiers to account names")
async def encounters_migrate_ids(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
await interaction.response.defer(ephemeral=True)
dm = self.bot.data_manager
pirates = dm.get('pirates')
by_char = {}
@ -433,10 +572,7 @@ class PirateReportCog(commands.Cog):
by_char.setdefault(p['character_name'].lower(), []).append(p)
by_acct = {p['account_name'].lower(): p for p in pirates}
changed = 0
ambiguous = 0
missing = 0
already = 0
changed = ambiguous = missing = already = 0
for e in dm.get('encounters'):
ident = e.get('identifier', '')
@ -454,26 +590,27 @@ class PirateReportCog(commands.Cog):
ambiguous += 1
continue
acct = matches[0]['account_name']
# update this one entry atomically
def pred(x, ts=e['timestamp'], rid=e['reporter_id'], ident_old=ident):
return x.get('timestamp') == ts and x.get('reporter_id') == rid and x.get('identifier') == ident_old
def upd(x, acct_new=acct):
x['identifier'] = acct_new
return x
ok = dm.update('encounters', pred, upd)
if ok:
changed += 1
await ctx.reply(
f"Migration complete.\n"
await interaction.followup.send(
"Migration complete.\n"
f"- Updated to accounts: **{changed}**\n"
f"- Already accounts: **{already}**\n"
f"- Ambiguous character names (skipped): **{ambiguous}**\n"
f"- Not found in pirates list (skipped): **{missing}**",
ephemeral=hasattr(ctx, "interaction") and ctx.interaction is not None
ephemeral=True
)
await self._refresh_pirates_list(ctx.guild)
await self._refresh_pirates_list(interaction.guild)
# Moderator reaction handling (atomic claim)
@commands.Cog.listener()
@ -516,20 +653,38 @@ class PirateReportCog(commands.Cog):
guild = channel.guild
stamp = _now_utc_str()
header_emoji = CHECK if approved else CROSS
new_content = (
f"{header_emoji} **Pirate Report**\n"
f"**Character:** {report['character_name']}\n"
f"**Account:** {report['account_name']}\n"
f"**Submitted by:** <@{report['submitter_id']}>\n\n"
f"**Status:** {'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}"
)
# Build new embed + content (keep proof visible if present)
status_text = f"{'Approved' if approved else 'Rejected'} by <@{payload.user_id}> on {stamp}"
report_view = {
'character_name': report['character_name'],
'account_name': report['account_name'],
'submitter_id': report['submitter_id'],
'proof_url': report.get('proof_url', ''),
'proof_type': report.get('proof_type', ''),
'status_line': status_text,
}
color = discord.Color.green() if approved else discord.Color.red()
title = "✅ Pirate Report — Approved" if approved else "❌ Pirate Report — Rejected"
new_embed = _make_report_embed(title, color, report_view, include_status=True)
# For videos, ensure the URL stays in message content so the inline player remains visible.
new_content = report_view['proof_url'] if report_view.get('proof_type') == 'video' else None
# Always (re)attach jump button if we have the ack info
view = None
try:
if report.get('origin_channel_id') and report.get('ack_message_id'):
url = _jump_url(guild.id, report['origin_channel_id'], report['ack_message_id'])
view = ReportJumpView(url)
except Exception:
view = None
try:
await msg.clear_reactions()
except Exception as e:
print("[pirate_report] clear reactions failed:", repr(e))
try:
await msg.edit(content=new_content)
await msg.edit(content=new_content, embed=new_embed, view=view)
except Exception as e:
print("[pirate_report] edit mod msg failed:", repr(e))
@ -559,21 +714,6 @@ class PirateReportCog(commands.Cog):
dm.remove('reports', lambda r: r.get('report_id') == msg.id)
async def setup(bot):
cog = PirateReportCog(bot)
await bot.add_cog(cog)
try:
home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
except Exception:
home_gid = 0
if home_gid:
guild_obj = discord.Object(id=home_gid)
bot.tree.add_command(cog.report, guild=guild_obj)
bot.tree.add_command(cog.edit_pirate, guild=guild_obj)
bot.tree.add_command(cog.encounter, guild=guild_obj)
else:
bot.tree.add_command(cog.report)
bot.tree.add_command(cog.edit_pirate)
bot.tree.add_command(cog.encounter)
await bot.add_cog(PirateReportCog(bot))

View File

@ -2,7 +2,9 @@
import asyncio
import discord
from discord.ext import commands
from mod_perms import require_mod_ctx # ctx-aware mod gate
from discord import app_commands
from mod_perms import require_mod_ctx, require_mod_interaction
from modules.common.settings import cfg as _cfg
class PiratesListCog(commands.Cog):
@ -16,29 +18,17 @@ class PiratesListCog(commands.Cog):
Posts are chunked to stay <2000 chars and previous posts are deleted on refresh.
"""
def __init__(self, bot):
def __init__(self, bot: commands.Bot):
self.bot = bot
cfg = bot.config["DEFAULT"]
self.list_channel_id = int(cfg["pirates_list_channel_id"])
try:
self.group_threshold = int(cfg.get("threat_group_threshold", "3"))
except Exception:
self.group_threshold = 3
try:
self.min_samples = int(cfg.get("threat_min_samples_for_stats", "3"))
except Exception:
self.min_samples = 3
c = _cfg(bot)
self.list_channel_id = c.int("pirates_list_channel_id")
self.group_threshold = c.int("threat_group_threshold", 3)
self.min_samples = c.int("threat_min_samples_for_stats", 3)
# serialize refreshes per guild
self._locks = {}
self._no_mentions = discord.AllowedMentions.none()
def _lock_for(self, guild_id: int):
import asyncio
self._locks.setdefault(guild_id, asyncio.Lock())
return self._locks[guild_id]
# send settings: never ping on posted content
self._locks: dict[int, asyncio.Lock] = {}
# never ping on posted content
self._no_mentions = discord.AllowedMentions.none()
# ----------------- utils -----------------
@ -119,30 +109,19 @@ class PiratesListCog(commands.Cog):
async def refresh_list(self, guild: discord.Guild):
"""Edit list messages in place; only send extra messages when we need more chunks (new pirates)."""
# ---- serialize per guild ----
lock = getattr(self, "_locks", {}).get(guild.id)
if lock is None:
# tiny fallback if you didn't add _lock_for()
import asyncio as _asyncio
if not hasattr(self, "_locks"):
self._locks = {}
self._locks[guild.id] = _asyncio.Lock()
lock = self._locks[guild.id]
async with lock:
async with self._lock_for(guild.id):
channel = guild.get_channel(self.list_channel_id)
if not channel:
print("[pirates_list] list channel not found:", self.list_channel_id)
return
dm = self.bot.data_manager
allow = getattr(self, "_no_mentions", discord.AllowedMentions.none())
allow = self._no_mentions
# ---- load & prune existing posts for this guild/channel ----
records = [r for r in dm.get("pirates_list_posts")
if r.get("guild_id") == guild.id and r.get("channel_id") == self.list_channel_id]
if r.get("guild_id") == guild.id and r.get("channel_id") == self.list_channel_id]
# fetch messages (drop any that vanished)
msgs, kept_records = [], []
for r in records:
try:
@ -150,11 +129,8 @@ class PiratesListCog(commands.Cog):
msgs.append(m)
kept_records.append(r)
except Exception:
# prune dead record
dm.remove("pirates_list_posts", lambda x, mid=r["message_id"]: x.get("message_id") == mid)
records = kept_records # only live ones, in stored order
# ---- build fresh, sorted contents ----
pirates = sorted(
dm.get("pirates"),
@ -168,13 +144,11 @@ class PiratesListCog(commands.Cog):
if not pirates:
placeholder = "_No verified pirates yet._"
if msgs:
# edit first, delete the rest
if msgs[0].content != placeholder:
try:
await msgs[0].edit(content=placeholder, allowed_mentions=allow)
except Exception as e:
print("[pirates_list] edit placeholder failed:", repr(e))
# remove extra posts/records
for extra in msgs[1:]:
try:
await extra.delete()
@ -214,7 +188,7 @@ class PiratesListCog(commands.Cog):
except Exception as e:
print("[pirates_list] edit block failed:", repr(e))
# ---- if we need *more* messages (usually after adding a pirate), send them ----
# ---- send additional messages if needed ----
if len(chunks) > len(msgs):
for i in range(len(msgs), len(chunks)):
try:
@ -227,7 +201,7 @@ class PiratesListCog(commands.Cog):
except Exception as e:
print("[pirates_list] send block failed:", repr(e))
# ---- if we need fewer messages (e.g., pirate removed), delete extras ----
# ---- delete extras if fewer chunks now ----
elif len(chunks) < len(msgs):
extras = msgs[len(chunks):]
for m in extras:
@ -238,19 +212,17 @@ class PiratesListCog(commands.Cog):
dm.remove("pirates_list_posts", lambda r, mid=m.id: r.get("message_id") == mid)
# Manual refresh command (hybrid: works as /pirates_list_refresh and !pirates_list_refresh)
@commands.hybrid_command(name="pirates_list_refresh", description="Rebuild the compact pirates list")
@commands.cooldown(1, 10, commands.BucketType.guild) # tiny anti-spam
async def pirates_list_refresh(self, ctx: commands.Context):
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
@app_commands.command(name="pirates_list_refresh", description="[MOD] Rebuild the compact pirates list")
@app_commands.checks.cooldown(1, 10) # guild-scope anti-spam analogue
async def pirates_list_refresh(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
if not await require_mod_interaction(interaction, "This command is restricted to moderators."):
return
if not ctx.guild:
return await ctx.reply("Use this in a server.", ephemeral=True)
await self.refresh_list(ctx.guild)
await interaction.response.defer(ephemeral=True)
await self.refresh_list(interaction.guild)
await interaction.followup.send("Pirates list refreshed.", ephemeral=True)
is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
await ctx.reply("Pirates list refreshed.", ephemeral=is_slash)
async def setup(bot):
async def setup(bot: commands.Bot):
await bot.add_cog(PiratesListCog(bot))

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

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ from typing import List, Dict, Tuple, Optional
import discord
from discord.ext import commands
from discord import app_commands
from modules.common.settings import cfg
# Accept both for backward compatibility; display uses "Refiner"
VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"}
@ -562,10 +563,10 @@ class SpicePayCog(commands.Cog):
self.bot = bot
self.sessions: Dict[tuple, Dict] = {}
cfg = bot.config['DEFAULT']
r = cfg(bot)
def _f(key, default):
try:
return float(cfg.get(key, str(default)))
return float(r.get(key, str(default)))
except Exception:
return float(default)
self.base_weight = _f('spicepay_base_weight', 25.0)
@ -575,7 +576,8 @@ class SpicePayCog(commands.Cog):
def _i(key):
try:
return int(cfg.get(key)) if cfg.get(key) else None
v = r.get(key, "")
return int(v) if v else None
except Exception:
return None
self.emoji_sand_id = _i('emoji_sand_id')
@ -679,7 +681,7 @@ class SpicePayCog(commands.Cog):
f"- Base weight: **{self.base_weight} × active %**\n"
f"- Carrier bonus: **+{self.carrier_bonus}**\n"
f"- Crawler bonus: **+{self.crawler_bonus}**\n\n"
"_Edit these in `settings.conf` under `[DEFAULT]` and restart the bot._"
"_Set via environment variables or your INI. Restart the bot after changing._"
)
await interaction.response.send_message(txt, ephemeral=True)
@ -895,23 +897,6 @@ class SpicePayCog(commands.Cog):
# ------------------------ setup ------------------------
async def setup(bot):
async def setup(bot: commands.Bot):
cog = SpicePayCog(bot)
await bot.add_cog(cog)
try:
home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0'))
except Exception:
home_gid = 0
if home_gid:
guild_obj = discord.Object(id=home_gid)
bot.tree.add_command(cog.spicepay, guild=guild_obj)
bot.tree.add_command(cog.spicepay_resume, guild=guild_obj)
bot.tree.add_command(cog.spicepay_cancel, guild=guild_obj)
bot.tree.add_command(cog.spicepay_config, guild=guild_obj)
else:
bot.tree.add_command(cog.spicepay)
bot.tree.add_command(cog.spicepay_resume)
bot.tree.add_command(cog.spicepay_cancel)
bot.tree.add_command(cog.spicepay_config)

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

View File

@ -1,9 +1,12 @@
# modules/user_cards/user_cards.py
import asyncio
import time
from typing import Optional, Set, Tuple
import discord
from discord.ext import commands
from discord import app_commands
from modules.common.emoji_accept import is_accept
from modules.common.settings import cfg # ENV-first helper
CHECK = '' # verified
CROSS = '' # not done
@ -11,6 +14,7 @@ PENDING = '✔️' # claimed / pending review
ACCEPT = {CHECK, '🫡'}
NO_MENTIONS = discord.AllowedMentions.none()
class UserCardsCog(commands.Cog):
"""
Per-user status cards with live reconcile and offline review triggers.
@ -25,34 +29,35 @@ class UserCardsCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
cfg = bot.config['DEFAULT']
self.userslist_channel_id = int(cfg['userslist_channel_id'])
self.modlog_channel_id = int(cfg['modlog_channel_id'])
self.mod_channel_id = int(cfg.get('mod_channel_id', '0') or 0)
r = cfg(bot)
# Channels / IDs from ENV/INI
self.userslist_channel_id = r.int('userslist_channel_id', 0)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
self.mod_channel_id = r.int('mod_channel_id', 0)
# reaction-role authoritative messages/roles
self.rules_msg_id = int(cfg['rules_message_id'])
self.engage_msg_id = int(cfg['engagement_message_id'])
self.nick_msg_id = int(cfg['nickname_message_id'])
self.rules_role_id = int(cfg['rules_role_id'])
self.engage_role_id = int(cfg['engagement_role_id'])
self.full_access_role_id = int(cfg['full_access_role_id'])
self.rules_msg_id = r.int('rules_message_id', 0)
self.engage_msg_id = r.int('engagement_message_id', 0)
self.nick_msg_id = r.int('nickname_message_id', 0)
self.rules_role_id = r.int('rules_role_id', 0)
self.engage_role_id = r.int('engagement_role_id', 0)
self.full_access_role_id = r.int('full_access_role_id', 0)
self._refresh_locks = {} # per-user locks to avoid racey double-posts
# Optional periodic refresh (twice a day)
try:
self.cron_enabled = cfg.getboolean('user_cards_cron_enabled')
except Exception:
self.cron_enabled = False
self.cron_enabled = r.bool('user_cards_cron_enabled', False)
self._cron_task = asyncio.create_task(self._periodic_refresh()) if self.cron_enabled else None
self._startup_task = asyncio.create_task(self._startup_reconcile())
def cog_unload(self):
for t in (self._cron_task, self._startup_task):
if t:
try: t.cancel()
except Exception: pass
try:
t.cancel()
except Exception:
pass
# ---------- status helpers ----------
@ -126,7 +131,7 @@ class UserCardsCog(commands.Cog):
if member.avatar:
embed.set_thumbnail(url=member.avatar.url)
# NEW: stable identity so we can find/edit the right card later
# Stable identity so we can find/edit the right card later
embed.set_footer(text=f"UID:{member.id}")
return embed
@ -137,7 +142,7 @@ class UserCardsCog(commands.Cog):
2) If not found, search the channel by footer marker and edit that.
3) If still not found, post a new one, then delete any stragglers with the same marker.
"""
if not member or not member.guild:
if not member or not member.guild or not self.userslist_channel_id:
return
async with self._lock_for(member.id):
@ -183,11 +188,13 @@ class UserCardsCog(commands.Cog):
return
# 4) Post fresh card
new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS)
try:
new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS)
except Exception:
return
# 5) Clean up any other messages that look like this user's card
try:
# Find any *other* occurrences with the same footer marker and delete them
marker = f"UID:{member.id}"
async for m in channel.history(limit=400, oldest_first=False):
if m.id == new_msg.id or m.author.id != self.bot.user.id or not m.embeds:
@ -211,7 +218,7 @@ class UserCardsCog(commands.Cog):
lk = asyncio.Lock()
self._refresh_locks[user_id] = lk
return lk
async def _find_existing_card(self, channel: discord.TextChannel, user_id: int) -> Optional[discord.Message]:
"""Search recent history for a card we posted for this user (by footer marker)."""
marker = f"UID:{user_id}"
@ -229,10 +236,14 @@ class UserCardsCog(commands.Cog):
return None
async def _log(self, guild: discord.Guild, content: str):
if not self.modlog_channel_id:
return
ch = guild.get_channel(self.modlog_channel_id)
if ch:
try: await ch.send(content, allowed_mentions=NO_MENTIONS)
except Exception: pass
try:
await ch.send(content, allowed_mentions=NO_MENTIONS)
except Exception:
pass
self.bot.data_manager.add('modlog', {'guild_id': guild.id, 'content': content})
# ---------- RR message lookup & reactor collection ----------
@ -249,6 +260,8 @@ class UserCardsCog(commands.Cog):
dm.add('rr_msg_channels', {'guild_id': guild_id, 'message_id': int(message_id), 'channel_id': int(channel_id)})
async def _get_message_by_id(self, guild: discord.Guild, message_id: int) -> Optional[discord.Message]:
if not message_id:
return None
ch_id = self._get_cached_msg_channel_id(guild.id, message_id)
if ch_id:
ch = guild.get_channel(ch_id)
@ -362,7 +375,7 @@ class UserCardsCog(commands.Cog):
except Exception:
pass
# --- New part: open reviews for *any* unreviewed claimers (startup/offline) ---
# --- Open reviews for *any* unreviewed claimers (startup/offline) ---
nn = self.bot.get_cog('NickNudgeCog')
verified_set = set(dm.get('nick_verified'))
@ -443,8 +456,10 @@ class UserCardsCog(commands.Cog):
for g in self.bot.guilds:
m = g.get_member(after.id)
if m:
try: await self.refresh_card(m)
except Exception: pass
try:
await self.refresh_card(m)
except Exception:
pass
# ---------- periodic + startup ----------
@ -458,16 +473,20 @@ class UserCardsCog(commands.Cog):
for g in list(self.bot.guilds):
for m in g.members:
if not m.bot:
try: await self.refresh_card(m)
except Exception: pass
try:
await self.refresh_card(m)
except Exception:
pass
async def _periodic_refresh(self):
await self.bot.wait_until_ready()
while not self.bot.is_closed():
try:
for g in self.bot.guilds:
try: await self._reconcile_agreements(g)
except Exception: pass
try:
await self._reconcile_agreements(g)
except Exception:
pass
for m in g.members:
if not m.bot:
await self.refresh_card(m)
@ -476,20 +495,18 @@ class UserCardsCog(commands.Cog):
await asyncio.sleep(12 * 60 * 60) # twice a day
# ---------- mod command: rescan + live reconcile ----------
@app_commands.command(name="usercards_rescan", description="[MOD] Re-check all users and refresh cards")
@app_commands.default_permissions(manage_guild=True)
@app_commands.checks.has_permissions(manage_guild=True)
async def usercards_rescan(self, interaction: discord.Interaction):
if not interaction.guild:
return await interaction.response.send_message("Use this in a server.", ephemeral=True)
@commands.hybrid_command(
name="usercards_rescan",
description="Re-check all users and refresh cards (mod-only)"
)
@commands.has_permissions(manage_guild=True)
async def usercards_rescan(self, ctx: commands.Context):
g = ctx.guild
if not g:
return await ctx.reply("Use this in a server.", ephemeral=True)
await interaction.response.defer(ephemeral=True)
g = interaction.guild
rchg, echg, nadd, nrem = await self._reconcile_agreements(g)
# Rebuild cards
updated = 0
for m in g.members:
if not m.bot:
@ -499,7 +516,7 @@ class UserCardsCog(commands.Cog):
except Exception:
pass
await ctx.reply(
await interaction.followup.send(
f"Reconciled from messages. Changes — Rules: **{rchg}**, RoE: **{echg}**, "
f"Nickname (added): **{nadd}**, Nickname (removed): **{nrem}**. "
f"Refreshed cards for **{updated}** members.",

View File

@ -1,2 +1,4 @@
discord.py>=2.3.2
python-dotenv
discord.py>=2.5.2
python-dotenv>=1.0.1
aiohttp>=3.9,<4
playwright==1.45.0