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
This commit is contained in:
parent
77f92abe19
commit
1ede582a76
@ -36,6 +36,7 @@
|
||||
.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; }
|
||||
@ -339,7 +340,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
|
||||
const backdrop=document.getElementById('backdrop');
|
||||
|
||||
function shownName(r){ return (r.display_name||r.name||'').replace(/^\//,''); }
|
||||
function helpSansMod(r){ return (r.help||'').replace(/^\s*\[MOD\]\s*/i,''); }
|
||||
function 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){
|
||||
@ -375,6 +376,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
|
||||
<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">
|
||||
@ -445,6 +447,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
|
||||
<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>
|
||||
|
22
assets/docs/commands/DDLootTableCog.dd_update.brief.html
Normal file
22
assets/docs/commands/DDLootTableCog.dd_update.brief.html
Normal file
@ -0,0 +1,22 @@
|
||||
<h2>/dd_update</h2>
|
||||
<p><strong>Control the Deep Desert weekly loot updater.</strong> Stop/resume the scheduler or force a one-off start.</p>
|
||||
|
||||
<h3>Usage</h3>
|
||||
<pre>/dd_update <action> [reason]</pre>
|
||||
<ul>
|
||||
<li><code>action</code> — one of <code>stop</code>, <code>resume</code>, <code>start</code>.</li>
|
||||
<li><code>reason</code> (optional) — short note shown in the confirmation.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Permissions</h3>
|
||||
<ul>
|
||||
<li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>
|
||||
<li><strong>start</strong>: <em>Server Owner only</em> (dangerous; bypasses the usual wait).</li>
|
||||
</ul>
|
||||
|
||||
<h3>What it does</h3>
|
||||
<ul>
|
||||
<li><em>stop</em> — pauses all checks until <code>resume</code>.</li>
|
||||
<li><em>resume</em> — returns to the normal weekly cycle.</li>
|
||||
<li><em>start</em> — behaves as if the weekly reset just happened and begins polling immediately.</li>
|
||||
</ul>
|
50
assets/docs/commands/DDLootTableCog.dd_update.details.html
Normal file
50
assets/docs/commands/DDLootTableCog.dd_update.details.html
Normal file
@ -0,0 +1,50 @@
|
||||
<h1>/dd_update — Deep Desert updater controls</h1>
|
||||
<p>Manage the weekly “Deep Desert — Weekly Uniques” message updater for this guild.</p>
|
||||
|
||||
<h2>Usage</h2>
|
||||
<pre>/dd_update <action> [reason]</pre>
|
||||
<ul>
|
||||
<li><code>action</code> — <code>stop</code> | <code>resume</code> | <code>start</code></li>
|
||||
<li><code>reason</code> (optional) — free text appended to the confirmation response.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Permissions</h2>
|
||||
<ul>
|
||||
<li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>
|
||||
<li><strong>start</strong>: <em>Server Owner only</em>. Use with care—this forces an immediate check cycle.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Behavior</h2>
|
||||
<ul>
|
||||
<li><strong>Weekly reset:</strong> Tuesdays <strong>03:00 UTC</strong>. At reset, the bot updates the channel message to indicate it’s waiting for the new week.</li>
|
||||
<li><strong>Polling cadence:</strong> adaptive back-off until fresh data is found:
|
||||
<ul>
|
||||
<li>Every 5 min for the first hour</li>
|
||||
<li>Then every 15 min until 3 hours</li>
|
||||
<li>Then every 30 min until 6 hours</li>
|
||||
<li>Then every 1 hour until 24 hours</li>
|
||||
<li>Then every 3 hours</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Success:</strong> when new valid data appears, the bot updates the message once and idles until the next weekly reset.</li>
|
||||
<li><strong>Errors / no update yet:</strong> the message shows a generic notice that it’s still waiting or that an issue occurred (no external source is mentioned).</li>
|
||||
</ul>
|
||||
|
||||
<h2>Actions</h2>
|
||||
<ul>
|
||||
<li><strong>stop</strong> — Pauses all checks. The message remains as-is until you <code>resume</code>.</li>
|
||||
<li><strong>resume</strong> — Returns to the normal weekly cycle (detect reset, then poll until new data).</li>
|
||||
<li><strong>start</strong> — Pretends the weekly reset just happened and begins polling now. <em>Owner-only.</em></li>
|
||||
</ul>
|
||||
|
||||
<h2>Channel & config</h2>
|
||||
<ul>
|
||||
<li><strong>Target channel:</strong> taken from <code>SHAI_DD_CHANNEL_ID</code> (env). If unset, defaults to <code>1404764793377652807</code>.</li>
|
||||
<li><strong>Scope:</strong> guild-specific. The message lives in this server’s configured channel.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Notes</h2>
|
||||
<ul>
|
||||
<li>This command only controls the updater; it does not manually edit the posted message.</li>
|
||||
<li>On each successful weekly update, the bot compares content against the previous week to avoid duplicate posts.</li>
|
||||
</ul>
|
@ -87,5 +87,9 @@
|
||||
"UserCardsCog.usercards_rescan": {
|
||||
"brief_html": "<h2>/usercards_rescan</h2>\n<p><strong>Re-check everyone and refresh the user cards.</strong> Also repairs Roles/RoE/nickname claims from the live reaction messages, and re-opens any missing nickname reviews.</p>\n\n<h3>Usage</h3>\n<pre>/usercards_rescan</pre>\n\n<ul>\n <li><strong>Moderator-only</strong> (requires <em>Manage Server</em>).</li>\n <li>Runs in the server; reply is ephemeral with a short summary.</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n <li>Reconciles from the configured Rules / RoE / Nickname reaction messages.</li>\n <li>Grants/removes the Rules & RoE roles to match reactions.</li>\n <li>For Nickname: opens a pending review if someone claimed but no review exists.</li>\n <li>Rebuilds/updates every user’s status card in the list channel.</li>\n</ul>\n",
|
||||
"details_html": "<h1>/usercards_rescan — Reconcile & refresh all cards</h1>\n<p>One-shot maintenance pass that makes the server’s user cards match reality.</p>\n\n<h2>Access</h2>\n<ul>\n <li><strong>Moderator-only</strong> — requires the Discord permission <em>Manage Server</em>.</li>\n <li>Must be used in a server channel (not DMs). The result is sent <em>ephemerally</em>.</li>\n</ul>\n\n<h2>What it fixes</h2>\n<ol>\n <li><strong>Rules / RoE agreement</strong>\n <ul>\n <liReads reactions from your configured Rules and RoE messages.</li>\n <li>Adds/removes the corresponding roles so roles match the reactions.</li>\n </ul>\n </li>\n <li><strong>Nickname claim & reviews</strong>\n <ul>\n <li>If a member has an “accept” reaction on the Nickname message but has <em>no</em> pending/verified record and <em>no</em> open review, it opens a <em>pending nickname review</em> for them.</li>\n <li>If a member <em>removed</em> their Nickname reaction, it clears any pending/verified state.</li>\n </ul>\n </li>\n <li><strong>User cards</strong>\n <ul>\n <li>Updates (or recreates) the embed for each member in the configured “users list” channel.</li>\n <li>Card color reflects: Rules, RoE, and Nickname status (✅ verified / ✔️ pending / ❌ not done).</li>\n <li>Uses a stable footer marker (<code>UID:<id></code>) to find/edit the right card; cleans up duplicates.</li>\n </ul>\n </li>\n</ol>\n\n<h2>Expected output</h2>\n<p>The command replies (ephemeral) with counts like:</p>\n<pre>Reconciled from messages. Changes — Rules: 3, RoE: 2, Nickname (added): 1, Nickname (removed): 0. Refreshed cards for 154 members.</pre>\n\n<h2>Setup notes</h2>\n<ul>\n <li>Relies on your configured IDs (ENV/INI): Rules/RoE/Nickname message IDs and their role IDs, the Full Access role, the user-cards channel, and the mod/modlog channels.</li>\n <li>Won’t ping anyone; all posts/edits are sent with <em>no mentions</em>.</li>\n</ul>\n\n<h2>Tips</h2>\n<ul>\n <li>Run after importing a server, restoring from backup, or after downtime.</li>\n <li>Large servers: this may take a moment while it walks members and edits cards. It’s safe to run again.</li>\n</ul>\n"
|
||||
},
|
||||
"DDLootTableCog.dd_update": {
|
||||
"brief_html": "<h2>/dd_update</h2>\n<p><strong>Control the Deep Desert weekly loot updater.</strong> Stop/resume the scheduler or force a one-off start.</p>\n\n<h3>Usage</h3>\n<pre>/dd_update <action> [reason]</pre>\n<ul>\n <li><code>action</code> — one of <code>stop</code>, <code>resume</code>, <code>start</code>.</li>\n <li><code>reason</code> (optional) — short note shown in the confirmation.</li>\n</ul>\n\n<h3>Permissions</h3>\n<ul>\n <li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>\n <li><strong>start</strong>: <em>Server Owner only</em> (dangerous; bypasses the usual wait).</li>\n</ul>\n\n<h3>What it does</h3>\n<ul>\n <li><em>stop</em> — pauses all checks until <code>resume</code>.</li>\n <li><em>resume</em> — returns to the normal weekly cycle.</li>\n <li><em>start</em> — behaves as if the weekly reset just happened and begins polling immediately.</li>\n</ul>\n",
|
||||
"details_html": "<h1>/dd_update — Deep Desert updater controls</h1>\n<p>Manage the weekly “Deep Desert — Weekly Uniques” message updater for this guild.</p>\n\n<h2>Usage</h2>\n<pre>/dd_update <action> [reason]</pre>\n<ul>\n <li><code>action</code> — <code>stop</code> | <code>resume</code> | <code>start</code></li>\n <li><code>reason</code> (optional) — free text appended to the confirmation response.</li>\n</ul>\n\n<h2>Permissions</h2>\n<ul>\n <li><strong>stop</strong> / <strong>resume</strong>: Server Owner or members with <em>Manage Server</em>.</li>\n <li><strong>start</strong>: <em>Server Owner only</em>. Use with care—this forces an immediate check cycle.</li>\n</ul>\n\n<h2>Behavior</h2>\n<ul>\n <li><strong>Weekly reset:</strong> Tuesdays <strong>03:00 UTC</strong>. At reset, the bot updates the channel message to indicate it’s waiting for the new week.</li>\n <li><strong>Polling cadence:</strong> adaptive back-off until fresh data is found:\n <ul>\n <li>Every 5 min for the first hour</li>\n <li>Then every 15 min until 3 hours</li>\n <li>Then every 30 min until 6 hours</li>\n <li>Then every 1 hour until 24 hours</li>\n <li>Then every 3 hours</li>\n </ul>\n </li>\n <li><strong>Success:</strong> when new valid data appears, the bot updates the message once and idles until the next weekly reset.</li>\n <li><strong>Errors / no update yet:</strong> the message shows a generic notice that it’s still waiting or that an issue occurred (no external source is mentioned).</li>\n</ul>\n\n<h2>Actions</h2>\n<ul>\n <li><strong>stop</strong> — Pauses all checks. The message remains as-is until you <code>resume</code>.</li>\n <li><strong>resume</strong> — Returns to the normal weekly cycle (detect reset, then poll until new data).</li>\n <li><strong>start</strong> — Pretends the weekly reset just happened and begins polling now. <em>Owner-only.</em></li>\n</ul>\n\n<h2>Channel & config</h2>\n<ul>\n <li><strong>Target channel:</strong> taken from <code>SHAI_DD_CHANNEL_ID</code> (env). If unset, defaults to <code>1404764793377652807</code>.</li>\n <li><strong>Scope:</strong> guild-specific. The message lives in this server’s configured channel.</li>\n</ul>\n\n<h2>Notes</h2>\n<ul>\n <li>This command only controls the updater; it does not manually edit the posted message.</li>\n <li>On each successful weekly update, the bot compares content against the previous week to avoid duplicate posts.</li>\n</ul>\n"
|
||||
}
|
||||
}
|
3
bot.py
3
bot.py
@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
|
||||
|
||||
# Version consists of:
|
||||
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
||||
VERSION = "0.4.1.0.a8"
|
||||
VERSION = "0.4.2.0.a1"
|
||||
|
||||
# ---------- Env loading ----------
|
||||
|
||||
@ -101,6 +101,7 @@ async def _guild_selfcheck(g: discord.Guild, c):
|
||||
_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}]")
|
||||
|
@ -181,6 +181,10 @@ async def post_boot_notice(bot):
|
||||
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:
|
||||
|
0
modules/dd/__init__.py
Normal file
0
modules/dd/__init__.py
Normal file
781
modules/dd/dd_loot_table.py
Normal file
781
modules/dd/dd_loot_table.py
Normal file
@ -0,0 +1,781 @@
|
||||
# modules/dd/dd_loot_table.py
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple, Literal
|
||||
|
||||
import aiohttp
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
|
||||
from modules.common.settings import cfg
|
||||
|
||||
DD_FALLBACK_CHANNEL = 1404764793377652807
|
||||
DD_URL = "https://dune.gaming.tools/deep-desert"
|
||||
OWNER_ID = 203190147582394369 # for error notices
|
||||
|
||||
def _log(*a): print("[DD]", *a)
|
||||
def _utcnow() -> datetime: return datetime.now(timezone.utc)
|
||||
|
||||
def _this_week_anchor(now: Optional[datetime] = None) -> datetime:
|
||||
if now is None: now = _utcnow()
|
||||
target_wd = 1 # Tue
|
||||
cur_wd = now.weekday()
|
||||
delta_days = (cur_wd - target_wd) % 7
|
||||
anchor_date = (now - timedelta(days=delta_days)).date()
|
||||
anchor_dt = datetime(anchor_date.year, anchor_date.month, anchor_date.day, 3, 0, 0, tzinfo=timezone.utc)
|
||||
if now < anchor_dt: anchor_dt -= timedelta(days=7)
|
||||
return anchor_dt
|
||||
|
||||
def _next_week_anchor(after: Optional[datetime] = None) -> datetime:
|
||||
return _this_week_anchor(after) + timedelta(days=7)
|
||||
|
||||
def _backoff_delay_secs(waiting_since: float, now_ts: float) -> int:
|
||||
waited = max(0.0, now_ts - waiting_since)
|
||||
if waited < 3600: return 5 * 60
|
||||
if waited < 3 * 3600: return 15 * 60
|
||||
if waited < 6 * 3600: return 30 * 60
|
||||
if waited < 24 * 3600: return 60 * 60
|
||||
return 3 * 60 * 60
|
||||
|
||||
@dataclass
|
||||
class DDState:
|
||||
channel_id: int
|
||||
message_id: Optional[int]
|
||||
disabled: bool
|
||||
|
||||
# hashes
|
||||
last_hash: str # current cycle
|
||||
prev_hash: str # previous cycle
|
||||
last_post_hash: str # hash of the message content currently posted
|
||||
|
||||
week_anchor_ts: int
|
||||
last_success_ts: int
|
||||
waiting_since_ts: int
|
||||
last_attempt_ts: int
|
||||
|
||||
@classmethod
|
||||
def from_dm(cls, dm) -> "DDState":
|
||||
rows = dm.get("dd_state")
|
||||
row = rows[0] if rows else {}
|
||||
env_raw = os.getenv("SHAI_DD_CHANNEL_ID", "").strip().strip('"').strip("'")
|
||||
env_cid = int(env_raw) if env_raw.isdigit() else 0
|
||||
try:
|
||||
stored_cid = int(row.get("channel_id") or 0)
|
||||
except Exception:
|
||||
stored_cid = 0
|
||||
chosen_cid = env_cid or stored_cid or DD_FALLBACK_CHANNEL
|
||||
return cls(
|
||||
channel_id=chosen_cid,
|
||||
message_id=row.get("message_id"),
|
||||
disabled=bool(row.get("disabled", False)),
|
||||
last_hash=str(row.get("last_hash", "")),
|
||||
prev_hash=str(row.get("prev_hash", "")),
|
||||
last_post_hash=str(row.get("last_post_hash", "")),
|
||||
week_anchor_ts=int(row.get("week_anchor_ts", 0)),
|
||||
last_success_ts=int(row.get("last_success_ts", 0)),
|
||||
waiting_since_ts=int(row.get("waiting_since_ts", 0)),
|
||||
last_attempt_ts=int(row.get("last_attempt_ts", 0)),
|
||||
)
|
||||
|
||||
def to_row(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"channel_id": self.channel_id,
|
||||
"message_id": self.message_id,
|
||||
"disabled": self.disabled,
|
||||
"last_hash": self.last_hash,
|
||||
"prev_hash": self.prev_hash,
|
||||
"last_post_hash": self.last_post_hash,
|
||||
"week_anchor_ts": self.week_anchor_ts,
|
||||
"last_success_ts": self.last_success_ts,
|
||||
"waiting_since_ts": self.waiting_since_ts,
|
||||
"last_attempt_ts": self.last_attempt_ts,
|
||||
}
|
||||
|
||||
# ---------- parsing ----------
|
||||
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
DETAILS_BLOCK_RE = re.compile(r"<details[^>]*>.*?</details>", re.I | re.S)
|
||||
NAME_SPAN_RE = re.compile(r"<summary[^>]*>.*?<span[^>]*>(?P<name>[^<]+)</span>.*?</summary>", re.I | re.S)
|
||||
ROW_RE = re.compile(
|
||||
r'<div[^>]*class="[^"]*flex[^"]*items-center[^"]*gap-2[^"]*"[^>]*>\s*'
|
||||
r'<div[^>]*class="[^"]*w-8[^"]*text-center[^"]*"[^>]*>\s*(?P<grid>[A-Z]\d+)\s*</div>\s*'
|
||||
r'<div[^>]*>\s*(?P<loc>[^<]+?)\s*</div>.*?'
|
||||
r'<div[^>]*class="[^"]*ml-auto[^"]*"[^>]*>.*?'
|
||||
r'<div[^>]*class="[^"]*w-10[^"]*text-center[^"]*"[^>]*>\s*(?P<amt>[^<]+?)\s*</div>\s*'
|
||||
r'<div[^>]*>\s*(?P<chance>~?\d+%|\d+\.\d+%)\s*</div>.*?'
|
||||
r'</div>\s*</div>',
|
||||
re.I | re.S,
|
||||
)
|
||||
|
||||
def _parse_dd_html(html: str) -> List[Dict[str, str]]:
|
||||
results: List[Dict[str, str]] = []
|
||||
for dmatch in DETAILS_BLOCK_RE.finditer(html or ""):
|
||||
block = dmatch.group(0)
|
||||
nmatch = NAME_SPAN_RE.search(block)
|
||||
if not nmatch: continue
|
||||
name = " ".join(nmatch.group("name").split())
|
||||
for rmatch in ROW_RE.finditer(block):
|
||||
grid = " ".join(rmatch.group("grid").split())
|
||||
loc = " ".join(rmatch.group("loc").split())
|
||||
amt = " ".join(rmatch.group("amt").split())
|
||||
chance = " ".join(rmatch.group("chance").split())
|
||||
results.append({"name": name, "grid": grid, "loc": loc, "amount": amt, "chance": chance})
|
||||
return results
|
||||
|
||||
def _hash_text(s: str) -> str:
|
||||
return hashlib.sha1(s.encode("utf-8")).hexdigest()
|
||||
|
||||
def _hash_records(rows) -> str:
|
||||
rows = _sanitize_rows(rows)
|
||||
m = hashlib.sha256()
|
||||
for r in rows:
|
||||
m.update(f"{r['name']}|{r['grid']}|{r['loc']}|{r['amount']}|{r['chance']}\n".encode("utf-8"))
|
||||
return m.hexdigest()
|
||||
|
||||
# ---------- formatters ----------
|
||||
|
||||
def _as_str(v) -> str:
|
||||
"""Coerce any value (incl. lists/tuples) to a compact string."""
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, (list, tuple, set)):
|
||||
try:
|
||||
return ", ".join(map(str, v))
|
||||
except Exception:
|
||||
return str(v)
|
||||
return str(v)
|
||||
|
||||
def _sanitize_rows(rows):
|
||||
"""Return rows with all fields as trimmed strings; safe for hashing/formatting."""
|
||||
out = []
|
||||
for r in rows or []:
|
||||
out.append({
|
||||
"name": _as_str(r.get("name", "")).strip(),
|
||||
"grid": _as_str(r.get("grid", "")).strip().upper(),
|
||||
"loc": _as_str(r.get("loc", "")).strip(),
|
||||
"amount": _as_str(r.get("amount", "")).strip().replace("–", "-"),
|
||||
"chance": _as_str(r.get("chance", "")).strip().replace(" ", ""),
|
||||
})
|
||||
return out
|
||||
|
||||
def _abbr_loc(loc: str) -> str:
|
||||
"""Shorten common locations to save characters."""
|
||||
m = {
|
||||
"Imperial Testing Station": "Imp. Testing Station",
|
||||
"Large Shipwreck": "L. Shipwreck",
|
||||
"Small Shipwreck": "S. Shipwreck",
|
||||
}
|
||||
return m.get(loc.strip(), loc.strip())
|
||||
|
||||
def _grid_sort_key(g: str):
|
||||
"""Sort grids like A1, A2, B10 naturally."""
|
||||
g = g.strip().upper()
|
||||
if not g:
|
||||
return ("Z", 999)
|
||||
letter, num = g[0], g[1:]
|
||||
try:
|
||||
n = int(num)
|
||||
except Exception:
|
||||
n = 999
|
||||
return (letter, n)
|
||||
|
||||
def _fit_discord_message(lines: list[str], header: str, budget: int = 1900) -> str:
|
||||
"""Join lines under budget with a truncation notice if needed."""
|
||||
out = [header]
|
||||
total = len(header) + 1
|
||||
dropped = 0
|
||||
for ln in lines:
|
||||
ln_len = len(ln) + 1
|
||||
if total + ln_len > budget:
|
||||
dropped += 1
|
||||
continue
|
||||
out.append(ln)
|
||||
total += ln_len
|
||||
if dropped:
|
||||
out.append(f"... _(truncated {dropped} lines)_")
|
||||
return "\n".join(out)
|
||||
|
||||
def _fmt_waiting(anchor_dt: datetime) -> str:
|
||||
when = anchor_dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
return ("**Deep Desert — Weekly Uniques**\n"
|
||||
f"_Reset detected (week starting **{when}**)._\n"
|
||||
"Waiting for the new loot table to appear...\n"
|
||||
"This message will update automatically once the new data is available.")
|
||||
|
||||
def _fmt_error(anchor_dt: datetime, note: str) -> str:
|
||||
when = anchor_dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
return ("**Deep Desert — Weekly Uniques**\n"
|
||||
f"_Week starting **{when}**._\n"
|
||||
f"⚠️ {note}\n"
|
||||
f"<@{OWNER_ID}> will investigate.")
|
||||
|
||||
def _fmt_rows(rows, anchor_dt: datetime) -> str:
|
||||
from collections import OrderedDict
|
||||
|
||||
rows = _sanitize_rows(rows)
|
||||
|
||||
def _abbr_loc(loc: str) -> str:
|
||||
m = {
|
||||
"Imperial Testing Station": "Imp. Testing Station",
|
||||
"Large Shipwreck": "L. Shipwreck",
|
||||
"Small Shipwreck": "S. Shipwreck",
|
||||
}
|
||||
return m.get(loc, loc)
|
||||
|
||||
def _grid_sort_key(g: str):
|
||||
g = (g or "").upper()
|
||||
if not g: return ("Z", 999)
|
||||
letter, num = g[0], g[1:]
|
||||
try: n = int(num)
|
||||
except: n = 999
|
||||
return (letter, n)
|
||||
|
||||
# item -> location -> (amount, chance) -> [grids]
|
||||
grouped: "OrderedDict[str, OrderedDict[str, Dict[Tuple[str, str], List[str]]]]" = OrderedDict()
|
||||
for r in sorted(rows, key=lambda x: (x["name"], _abbr_loc(x["loc"]), _grid_sort_key(x["grid"]))):
|
||||
item, loc, grid, amt, ch = r["name"], _abbr_loc(r["loc"]), r["grid"], r["amount"], r["chance"]
|
||||
grouped.setdefault(item, OrderedDict()).setdefault(loc, {}).setdefault((amt, ch), []).append(grid)
|
||||
|
||||
lines = []
|
||||
for item, loc_map in grouped.items():
|
||||
lines.append(f"- **{item}**")
|
||||
for loc, by_ac in loc_map.items():
|
||||
lines.append(f" - {loc}")
|
||||
|
||||
def _sort_ac(k):
|
||||
amt, ch = k
|
||||
try:
|
||||
chv = float(ch.lstrip("~").rstrip("%"))
|
||||
except Exception:
|
||||
chv = -1.0
|
||||
return (-chv, amt)
|
||||
|
||||
for (amt, ch), grids in sorted(by_ac.items(), key=_sort_ac):
|
||||
gstr = ", ".join(sorted(set(grids), key=_grid_sort_key))
|
||||
lines.append(f" - {gstr} - {amt} ({ch})")
|
||||
|
||||
when = anchor_dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
header = f"**Deep Desert — Weekly Uniques** _(week starting **{when}**)_"
|
||||
return _fit_discord_message(lines, header, budget=1900)
|
||||
|
||||
# ---------- HTTP fetchers ----------
|
||||
|
||||
async def _fetch_via_aiohttp(session: aiohttp.ClientSession, url: str) -> str:
|
||||
headers = {
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=20, sock_connect=10, sock_read=10)
|
||||
async with session.get(url, headers=headers, allow_redirects=True, timeout=timeout) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
raise aiohttp.ClientResponseError(
|
||||
request_info=resp.request_info, history=resp.history,
|
||||
status=resp.status, message=f"HTTP {resp.status}", headers=resp.headers
|
||||
)
|
||||
return text
|
||||
|
||||
# ---------- Playwright (headless) ----------
|
||||
|
||||
class _PlaywrightPool:
|
||||
"""Lazy, optional Playwright Chromium pool (single context)."""
|
||||
def __init__(self):
|
||||
self.apw = None
|
||||
self.browser = None
|
||||
self.context = None
|
||||
self.enabled = False
|
||||
|
||||
async def ensure(self) -> bool:
|
||||
if self.enabled and self.apw and self.browser and self.context:
|
||||
return True
|
||||
try:
|
||||
from playwright.async_api import async_playwright # type: ignore
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
self.apw = await async_playwright().start()
|
||||
# flags for container/root environments + reduce automation signals
|
||||
self.browser = await self.apw.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
],
|
||||
)
|
||||
self.context = await self.browser.new_context(
|
||||
user_agent=_USER_AGENT,
|
||||
locale="en-US",
|
||||
timezone_id="UTC",
|
||||
java_script_enabled=True,
|
||||
ignore_https_errors=True,
|
||||
viewport={"width": 1366, "height": 900},
|
||||
extra_http_headers={
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
},
|
||||
)
|
||||
# Minimal stealth: remove webdriver and add a few common props
|
||||
await self.context.add_init_script("""
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5] });
|
||||
""")
|
||||
self.enabled = True
|
||||
return True
|
||||
|
||||
async def close(self):
|
||||
try:
|
||||
if self.context: await self.context.close()
|
||||
finally:
|
||||
try:
|
||||
if self.browser: await self.browser.close()
|
||||
finally:
|
||||
try:
|
||||
if self.apw: await self.apw.stop()
|
||||
finally:
|
||||
self.apw = self.browser = self.context = None
|
||||
self.enabled = False
|
||||
|
||||
async def fetch(self, url: str, timeout_ms: Optional[int] = None, wait: Optional[str] = None) -> str:
|
||||
"""
|
||||
Fetch fully rendered HTML with tolerant waiting against Cloudflare.
|
||||
Env overrides:
|
||||
SHAI_DD_PW_TIMEOUT_MS (default 45000)
|
||||
SHAI_DD_PW_WAIT = domcontentloaded|load|networkidle (default domcontentloaded)
|
||||
"""
|
||||
if not await self.ensure():
|
||||
raise RuntimeError("playwright-unavailable")
|
||||
|
||||
timeout_ms = int(os.getenv("SHAI_DD_PW_TIMEOUT_MS", "45000") or "45000") if timeout_ms is None else timeout_ms
|
||||
wait_mode = (os.getenv("SHAI_DD_PW_WAIT", "domcontentloaded") or "domcontentloaded").lower()
|
||||
if wait: wait_mode = wait
|
||||
|
||||
page = await self.context.new_page()
|
||||
|
||||
# Keep media traffic low but don't block fonts/CSS/JS (CF sometimes needs them)
|
||||
async def _route(route):
|
||||
rt = route.request.resource_type
|
||||
if rt in ("media", "video", "audio"):
|
||||
await route.abort()
|
||||
else:
|
||||
await route.continue_()
|
||||
await page.route("**/*", _route)
|
||||
|
||||
# Step 1: navigate, but don't require networkidle (CF pages rarely go "idle")
|
||||
await page.goto(url, wait_until=wait_mode, timeout=timeout_ms)
|
||||
|
||||
# Step 2: loop for CF auto-redirect and app hydration
|
||||
# We'll try up to ~35s total here.
|
||||
end_by = time.time() + max(20, timeout_ms / 1000 - 5)
|
||||
last_details = 0
|
||||
while time.time() < end_by:
|
||||
html = await page.content()
|
||||
u = page.url
|
||||
# If we're still on a CF challenge or "just a moment" page, give it a bit
|
||||
if ("cdn-cgi/challenge" in u) or ("cf-chl" in u) or ("Just a moment" in html) or ("Please wait" in html):
|
||||
await page.wait_for_timeout(2500)
|
||||
continue
|
||||
|
||||
# Check if our target content looks present
|
||||
try:
|
||||
count = await page.locator("details").count()
|
||||
except Exception:
|
||||
count = 0
|
||||
last_details = max(last_details, count)
|
||||
if count > 0:
|
||||
break
|
||||
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
html = await page.content()
|
||||
await page.close()
|
||||
return html
|
||||
|
||||
# ---------- Cog ----------
|
||||
|
||||
class DDLootTableCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
r = cfg(bot)
|
||||
self.dd_url = r.get("dd_url", DD_URL)
|
||||
try:
|
||||
self.channel_id_default = int(r.get("dd_channel_id", DD_FALLBACK_CHANNEL))
|
||||
except Exception:
|
||||
self.channel_id_default = DD_FALLBACK_CHANNEL
|
||||
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self._pw = _PlaywrightPool()
|
||||
self._last_debug: str = ""
|
||||
|
||||
async def cog_load(self):
|
||||
self._session = aiohttp.ClientSession()
|
||||
if self._task is None:
|
||||
self._task = asyncio.create_task(self._runner(), name="DDLootTableRunner")
|
||||
_log("cog loaded; runner started:", bool(self._task), "url:", self.dd_url)
|
||||
|
||||
async def cog_unload(self):
|
||||
t, self._task = self._task, None
|
||||
if t: t.cancel()
|
||||
s, self._session = self._session, None
|
||||
if s: await s.close()
|
||||
try:
|
||||
await self._pw.close()
|
||||
except Exception:
|
||||
pass
|
||||
_log("cog unloaded; runner/task closed")
|
||||
|
||||
# ---- state ----
|
||||
|
||||
def _load_state(self) -> DDState:
|
||||
st = DDState.from_dm(self.bot.data_manager)
|
||||
env_raw = os.getenv("SHAI_DD_CHANNEL_ID", "").strip().strip('"').strip("'")
|
||||
env_cid = int(env_raw) if env_raw.isdigit() else 0
|
||||
if env_cid and env_cid != st.channel_id:
|
||||
st.channel_id = env_cid
|
||||
self._save_state(st.to_row())
|
||||
_log(f"channel id overridden by ENV -> {env_cid}")
|
||||
_log(f"state loaded: ch={st.channel_id} msg={st.message_id} disabled={st.disabled}")
|
||||
return st
|
||||
|
||||
def _save_state(self, patch: Dict[str, Any]) -> None:
|
||||
dm = self.bot.data_manager
|
||||
rows = dm.get("dd_state")
|
||||
if not rows:
|
||||
dm.add("dd_state", patch); return
|
||||
def pred(_): return True
|
||||
def upd(d): d.update(patch); return d
|
||||
dm.update("dd_state", pred, upd)
|
||||
|
||||
# ---- message helpers ----
|
||||
|
||||
async def _resolve_channel(self, channel_id: int) -> Optional[discord.TextChannel]:
|
||||
ch = self.bot.get_channel(channel_id)
|
||||
if ch is None:
|
||||
try: ch = await self.bot.fetch_channel(channel_id)
|
||||
except Exception: ch = None
|
||||
if not isinstance(ch, discord.TextChannel): return None
|
||||
me = ch.guild.me
|
||||
if me:
|
||||
p = ch.permissions_for(me)
|
||||
if not (p.read_messages and p.send_messages):
|
||||
_log(f"missing perms in #{ch.name} ({ch.id})")
|
||||
return ch
|
||||
|
||||
async def _ensure_message(self, st: DDState, content_if_create: Optional[str]) -> Optional[discord.Message]:
|
||||
ch = await self._resolve_channel(st.channel_id)
|
||||
if not ch:
|
||||
_log("target channel not found/invalid:", st.channel_id)
|
||||
return None
|
||||
|
||||
if st.message_id:
|
||||
try:
|
||||
return await ch.fetch_message(st.message_id)
|
||||
except discord.NotFound:
|
||||
st.message_id = None
|
||||
self._save_state({"message_id": None})
|
||||
except discord.Forbidden:
|
||||
_log("cannot fetch message (no history); will NOT create a new one")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log("fetch_message failed:", repr(e))
|
||||
return None
|
||||
|
||||
if content_if_create is None:
|
||||
return None
|
||||
try:
|
||||
msg = await ch.send(content_if_create)
|
||||
st.message_id = msg.id
|
||||
st.last_post_hash = _hash_text(content_if_create)
|
||||
self._save_state({"channel_id": st.channel_id, "message_id": msg.id, "last_post_hash": st.last_post_hash})
|
||||
return msg
|
||||
except Exception as e:
|
||||
_log("failed to create message:", repr(e))
|
||||
return None
|
||||
|
||||
async def _set_message(self, st: DDState, content: str) -> Optional[int]:
|
||||
"""Create-or-edit the single managed message. Returns message_id (if known) and stores last_post_hash."""
|
||||
msg = await self._ensure_message(st, content_if_create=content if not st.message_id else None)
|
||||
if not msg:
|
||||
return None
|
||||
try:
|
||||
await msg.edit(content=content)
|
||||
st.last_post_hash = _hash_text(content)
|
||||
self._save_state({"last_post_hash": st.last_post_hash})
|
||||
except discord.NotFound:
|
||||
st.message_id = None
|
||||
self._save_state({"message_id": None})
|
||||
msg2 = await self._ensure_message(st, content_if_create=content)
|
||||
if msg2:
|
||||
try:
|
||||
await msg2.edit(content=content)
|
||||
st.last_post_hash = _hash_text(content)
|
||||
self._save_state({"message_id": msg2.id, "last_post_hash": st.last_post_hash})
|
||||
except Exception:
|
||||
pass
|
||||
except discord.Forbidden:
|
||||
_log("edit forbidden; single-message mode keeps state")
|
||||
except Exception as e:
|
||||
_log("edit failed:", repr(e))
|
||||
return st.message_id
|
||||
|
||||
# ---- fetch orchestration ----
|
||||
|
||||
async def _fetch_dd_html_any(self) -> Tuple[str, str]:
|
||||
"""Return (html, backend_tag). Preference: env → playwright(if available) → aiohttp."""
|
||||
prefer = os.getenv("SHAI_DD_FETCHER", "").lower()
|
||||
# prefer Playwright
|
||||
if prefer in {"playwright", "pw", "browser"}:
|
||||
if await self._pw.ensure():
|
||||
html = await self._pw.fetch(self.dd_url)
|
||||
return html, "playwright"
|
||||
else:
|
||||
# opportunistic: try Playwright first if available
|
||||
try:
|
||||
if await self._pw.ensure():
|
||||
html = await self._pw.fetch(self.dd_url)
|
||||
return html, "playwright"
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: aiohttp (may 403)
|
||||
html = await _fetch_via_aiohttp(self._session, self.dd_url)
|
||||
return html, "aiohttp"
|
||||
|
||||
async def _attempt_fetch(self) -> Tuple[bool, List[Dict[str, str]], str]:
|
||||
import asyncio
|
||||
self._last_debug = ""
|
||||
if not self._session:
|
||||
self._last_debug = "internal: no HTTP session"
|
||||
return (False, [], "unable to check for new loot (will retry)")
|
||||
try:
|
||||
html, backend = await self._fetch_dd_html_any()
|
||||
self._last_debug = f"ok via {backend}"
|
||||
except aiohttp.ClientResponseError as e:
|
||||
self._last_debug = f"http {getattr(e,'status','?')} (aiohttp)"
|
||||
return (False, [], "unable to check for new loot (will retry)")
|
||||
except asyncio.TimeoutError:
|
||||
self._last_debug = "timeout"
|
||||
return (False, [], "unable to check for new loot (will retry)")
|
||||
except Exception as e:
|
||||
self._last_debug = f"{e.__class__.__name__}: {e}"
|
||||
return (False, [], "unable to check for new loot (will retry)")
|
||||
|
||||
try:
|
||||
rows = _parse_dd_html(html)
|
||||
if not rows:
|
||||
self._last_debug = "parse: zero rows"
|
||||
return (False, [], "no loot entries detected yet (will retry)")
|
||||
clean = []
|
||||
for r in rows:
|
||||
name = r["name"].strip()
|
||||
grid = r["grid"].strip().upper()
|
||||
loc = r["loc"].strip()
|
||||
amt = r["amount"].strip().replace("–", "-")
|
||||
chance = r["chance"].strip().replace(" ", "")
|
||||
if not name or not re.match(r"^[A-Z]\d+$", grid):
|
||||
continue
|
||||
clean.append({"name": name, "grid": grid, "loc": loc, "amount": amt, "chance": chance})
|
||||
if not clean:
|
||||
self._last_debug = "parse: filtered to zero rows"
|
||||
return (False, [], "loot data format changed (will retry)")
|
||||
return (True, clean, "")
|
||||
except Exception as e:
|
||||
self._last_debug = f"parse error: {e.__class__.__name__}: {e}"
|
||||
return (False, [], "loot data parse error (will retry)")
|
||||
|
||||
# ---- manual kick ----
|
||||
|
||||
async def _manual_kick_once(self, st: DDState) -> str:
|
||||
anchor_dt = _this_week_anchor()
|
||||
|
||||
# always show "waiting" briefly so users see it's been kicked
|
||||
mid = await self._set_message(st, _fmt_waiting(anchor_dt))
|
||||
if mid and not st.message_id:
|
||||
st.message_id = mid
|
||||
self._save_state(st.to_row())
|
||||
|
||||
ok, rows, note = await self._attempt_fetch()
|
||||
if not ok or not rows:
|
||||
if note:
|
||||
await self._set_message(st, _fmt_error(anchor_dt, note))
|
||||
return f"Fetch failed: {note or 'unknown error'}"
|
||||
|
||||
new_hash = _hash_records(rows)
|
||||
|
||||
if st.prev_hash and new_hash == st.prev_hash:
|
||||
# still last week's data; keep waiting
|
||||
await self._set_message(st, _fmt_waiting(anchor_dt))
|
||||
return "Data unchanged from previous cycle; still waiting."
|
||||
|
||||
table = _fmt_rows(rows, anchor_dt)
|
||||
|
||||
if st.last_hash and new_hash == st.last_hash:
|
||||
# same as what we already posted this cycle → ensure table is visible
|
||||
await self._set_message(st, table)
|
||||
return "Data unchanged; table ensured."
|
||||
|
||||
# fresh for this cycle
|
||||
st.last_hash = new_hash
|
||||
st.last_success_ts = int(time.time())
|
||||
self._save_state(st.to_row())
|
||||
await self._set_message(st, table)
|
||||
return "Posted fresh data."
|
||||
|
||||
# ---- runner ----
|
||||
|
||||
async def _runner(self):
|
||||
await self.bot.wait_until_ready()
|
||||
_log("runner loop started")
|
||||
while not self.bot.is_closed():
|
||||
try:
|
||||
st = self._load_state()
|
||||
if st.disabled:
|
||||
await asyncio.sleep(300); continue
|
||||
|
||||
now_dt = _utcnow()
|
||||
this_anchor_dt = _this_week_anchor(now_dt)
|
||||
this_anchor_ts = int(this_anchor_dt.timestamp())
|
||||
next_anchor_dt = _next_week_anchor(now_dt)
|
||||
|
||||
if st.week_anchor_ts != this_anchor_ts:
|
||||
# roll current → prev; reset current
|
||||
st.prev_hash = st.last_hash or st.prev_hash
|
||||
st.last_hash = ""
|
||||
st.week_anchor_ts = this_anchor_ts
|
||||
st.last_success_ts = 0
|
||||
st.waiting_since_ts = this_anchor_ts
|
||||
st.last_attempt_ts = 0
|
||||
self._save_state(st.to_row())
|
||||
|
||||
mid = await self._set_message(st, _fmt_waiting(this_anchor_dt))
|
||||
if mid and not st.message_id:
|
||||
st.message_id = mid
|
||||
self._save_state(st.to_row())
|
||||
_log("new week anchor -> waiting UPDATED (single-message)")
|
||||
|
||||
if st.last_success_ts >= this_anchor_ts and st.last_success_ts < int(next_anchor_dt.timestamp()):
|
||||
await asyncio.sleep(min(3600, max(60, int(next_anchor_dt.timestamp() - time.time()))))
|
||||
continue
|
||||
|
||||
if st.waiting_since_ts == 0:
|
||||
st.waiting_since_ts = this_anchor_ts
|
||||
|
||||
delay = _backoff_delay_secs(st.waiting_since_ts, time.time())
|
||||
|
||||
if st.last_attempt_ts == 0 or (time.time() - st.last_attempt_ts) >= delay:
|
||||
ok, rows, note = await self._attempt_fetch()
|
||||
st.last_attempt_ts = int(time.time())
|
||||
self._save_state(st.to_row())
|
||||
|
||||
if ok and rows:
|
||||
new_hash = _hash_records(rows)
|
||||
|
||||
# 1) identical to last cycle → keep waiting; keep polling
|
||||
if st.prev_hash and new_hash == st.prev_hash:
|
||||
waiting = _fmt_waiting(this_anchor_dt)
|
||||
if st.last_post_hash != _hash_text(waiting):
|
||||
await self._set_message(st, waiting)
|
||||
_log("data equals prev week; still waiting")
|
||||
# no success_ts update; try again with backoff
|
||||
else:
|
||||
table = _fmt_rows(rows, this_anchor_dt)
|
||||
|
||||
# 2) same as current hash → ensure table is visible (flip off any waiting message)
|
||||
if st.last_hash and new_hash == st.last_hash:
|
||||
if st.last_post_hash != _hash_text(table):
|
||||
await self._set_message(st, table)
|
||||
_log("data same as already posted; ensured table visible")
|
||||
# already have success this cycle; sleep a bit longer
|
||||
await asyncio.sleep(900)
|
||||
continue
|
||||
|
||||
# 3) fresh data for this cycle → post table, mark success
|
||||
st.last_hash = new_hash
|
||||
st.last_success_ts = int(time.time())
|
||||
self._save_state(st.to_row())
|
||||
await self._set_message(st, table)
|
||||
_log("updated weekly uniques (fresh data)")
|
||||
await asyncio.sleep(900)
|
||||
continue
|
||||
|
||||
else:
|
||||
if note:
|
||||
await self._set_message(st, _fmt_error(this_anchor_dt, note))
|
||||
_log("fetch failed:", note, "| debug:", self._last_debug)
|
||||
|
||||
await asyncio.sleep(30)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
_log("runner error:", repr(e)); await asyncio.sleep(30)
|
||||
_log("runner loop stopped")
|
||||
|
||||
# ---- command ----
|
||||
|
||||
@app_commands.command(name="dd_update", description="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))
|
@ -290,8 +290,8 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
|
||||
"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,
|
||||
# NEW: counter fields
|
||||
"counter_key": qn,
|
||||
"exec_count": _cmd_counter(bot, qn),
|
||||
}
|
||||
@ -364,10 +364,10 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
|
||||
"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),
|
||||
# NEW: counter fields
|
||||
"counter_key": qn,
|
||||
"exec_count": _cmd_counter(bot, qn),
|
||||
}
|
||||
@ -565,6 +565,8 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
|
||||
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"):
|
||||
@ -598,8 +600,11 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
|
||||
for row in all_rows:
|
||||
try:
|
||||
helptext = f"{row.get('help') or ''} {row.get('brief') or ''}"
|
||||
if "[mod]" in helptext.lower():
|
||||
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"):
|
||||
@ -608,6 +613,8 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
|
||||
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
|
||||
|
||||
@ -622,8 +629,8 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
|
||||
|
||||
all_rows.sort(key=_sort_key)
|
||||
|
||||
mods = [r for r in all_rows if r.get("moderator_only")]
|
||||
users = [r for r in all_rows if not r.get("moderator_only")]
|
||||
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",
|
||||
|
Loading…
Reference in New Issue
Block a user