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; }
 | 
					  .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 { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #2b4; }
 | 
				
			||||||
  .pill.mod { border-color:#ef4444; color:#fecaca; }
 | 
					  .pill.mod { border-color:#ef4444; color:#fecaca; }
 | 
				
			||||||
 | 
					  .pill.admin { border-color:#a78bfa; color:#e9d5ff; }
 | 
				
			||||||
  .pill.slash { border-color:#60a5fa; }
 | 
					  .pill.slash { border-color:#60a5fa; }
 | 
				
			||||||
  .pill.prefix { border-color:#f59e0b; }
 | 
					  .pill.prefix { border-color:#f59e0b; }
 | 
				
			||||||
  .pill.hybrid { border-color:#34d399; }
 | 
					  .pill.hybrid { border-color:#34d399; }
 | 
				
			||||||
@ -339,7 +340,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
				
			|||||||
  const backdrop=document.getElementById('backdrop');
 | 
					  const backdrop=document.getElementById('backdrop');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function shownName(r){ return (r.display_name||r.name||'').replace(/^\//,''); }
 | 
					  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\./,''); }
 | 
					  function moduleSansPrefix(r){ const m=r.module||''; return m.replace(/^modules?\./,'').replace(/^discord\.ext\./,''); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async function shareFor(r){
 | 
					  async function shareFor(r){
 | 
				
			||||||
@ -375,6 +376,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
				
			|||||||
      <div class="name" style="margin-bottom:6px">
 | 
					      <div class="name" style="margin-bottom:6px">
 | 
				
			||||||
        <span class="pill ${r.type}">${r.type}</span>
 | 
					        <span class="pill ${r.type}">${r.type}</span>
 | 
				
			||||||
        ${r.moderator_only?'<span class="pill mod">mod</span>':''}
 | 
					        ${r.moderator_only?'<span class="pill mod">mod</span>':''}
 | 
				
			||||||
 | 
					        ${r.admin_only?'<span class="pill admin">admin</span>':''}
 | 
				
			||||||
        <span>${shownName(r)}</span>
 | 
					        <span>${shownName(r)}</span>
 | 
				
			||||||
        <span style="flex:1"></span>
 | 
					        <span style="flex:1"></span>
 | 
				
			||||||
        <div class="btn-row">
 | 
					        <div class="btn-row">
 | 
				
			||||||
@ -445,6 +447,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
				
			|||||||
      <div class="name">
 | 
					      <div class="name">
 | 
				
			||||||
        <span class="pill ${r.type}">${r.type}</span>
 | 
					        <span class="pill ${r.type}">${r.type}</span>
 | 
				
			||||||
        ${r.moderator_only?'<span class="pill mod">mod</span>':''}
 | 
					        ${r.moderator_only?'<span class="pill mod">mod</span>':''}
 | 
				
			||||||
 | 
					        ${r.admin_only?'<span class="pill admin">admin</span>':''}
 | 
				
			||||||
        <span>${shownName(r)}</span>
 | 
					        <span>${shownName(r)}</span>
 | 
				
			||||||
        <div class="btn-row">
 | 
					        <div class="btn-row">
 | 
				
			||||||
          <button class="btn btn-icon" title="Copy link" data-share="1">🔗</button>
 | 
					          <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": {
 | 
					  "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",
 | 
					    "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"
 | 
					    "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:
 | 
					# Version consists of:
 | 
				
			||||||
# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
					# 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 ----------
 | 
					# ---------- 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('mod_channel_id', 'read_messages', 'send_messages', 'add_reactions', 'read_message_history')
 | 
				
			||||||
    _need_channel('modlog_channel_id', 'read_messages', 'send_messages')
 | 
					    _need_channel('modlog_channel_id', 'read_messages', 'send_messages')
 | 
				
			||||||
    _need_channel('pirates_list_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:
 | 
					    if problems:
 | 
				
			||||||
        print(f"[SelfCheck:{g.name}]")
 | 
					        print(f"[SelfCheck:{g.name}]")
 | 
				
			||||||
 | 
				
			|||||||
@ -181,6 +181,10 @@ async def post_boot_notice(bot):
 | 
				
			|||||||
    except Exception as e:
 | 
					    except Exception as e:
 | 
				
			||||||
        print(f"[boot_notice] wait_until_ready failed: {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)
 | 
					    r = cfg(bot)
 | 
				
			||||||
    modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
					    modlog_channel_id = r.int('modlog_channel_id', 0)
 | 
				
			||||||
    if not modlog_channel_id:
 | 
					    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,
 | 
					                "cog": getattr(cmd.cog, "qualified_name", None) if getattr(cmd, "cog", None) else None,
 | 
				
			||||||
                "module": getattr(getattr(cmd, "callback", None), "__module__", None),
 | 
					                "module": getattr(getattr(cmd, "callback", None), "__module__", None),
 | 
				
			||||||
                "moderator_only": bool(is_mod),
 | 
					                "moderator_only": bool(is_mod),
 | 
				
			||||||
 | 
					                "admin_only": False,
 | 
				
			||||||
                "required_permissions": perms,
 | 
					                "required_permissions": perms,
 | 
				
			||||||
                # NEW: counter fields
 | 
					 | 
				
			||||||
                "counter_key": qn,
 | 
					                "counter_key": qn,
 | 
				
			||||||
                "exec_count": _cmd_counter(bot, 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,
 | 
					                "cog": binding.__class__.__name__ if binding else None,
 | 
				
			||||||
                "module": getattr(callback, "__module__", None) if callback else None,
 | 
					                "module": getattr(callback, "__module__", None) if callback else None,
 | 
				
			||||||
                "moderator_only": bool(is_mod),
 | 
					                "moderator_only": bool(is_mod),
 | 
				
			||||||
 | 
					                "admin_only": False,
 | 
				
			||||||
                "required_permissions": perms,
 | 
					                "required_permissions": perms,
 | 
				
			||||||
                "extras": _safe_extras(leaf),
 | 
					                "extras": _safe_extras(leaf),
 | 
				
			||||||
                "dm_permission": getattr(leaf, "dm_permission", None),
 | 
					                "dm_permission": getattr(leaf, "dm_permission", None),
 | 
				
			||||||
                # NEW: counter fields
 | 
					 | 
				
			||||||
                "counter_key": qn,
 | 
					                "counter_key": qn,
 | 
				
			||||||
                "exec_count": _cmd_counter(bot, 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"]
 | 
					            h["help"] = r["help"]
 | 
				
			||||||
        if r.get("moderator_only"):
 | 
					        if r.get("moderator_only"):
 | 
				
			||||||
            h["moderator_only"] = True
 | 
					            h["moderator_only"] = True
 | 
				
			||||||
 | 
					        if r.get("admin_only"):
 | 
				
			||||||
 | 
					            h["admin_only"] = True
 | 
				
			||||||
        if r.get("required_permissions"):
 | 
					        if r.get("required_permissions"):
 | 
				
			||||||
            h["required_permissions"] = sorted(set((h.get("required_permissions") or []) + r["required_permissions"]))
 | 
					            h["required_permissions"] = sorted(set((h.get("required_permissions") or []) + r["required_permissions"]))
 | 
				
			||||||
        if not h.get("extras") and r.get("extras"):
 | 
					        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:
 | 
					    for row in all_rows:
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            helptext = f"{row.get('help') or ''} {row.get('brief') or ''}"
 | 
					            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
 | 
					                row["moderator_only"] = True
 | 
				
			||||||
 | 
					            if "[admin]" in hl:
 | 
				
			||||||
 | 
					                row["admin_only"] = True
 | 
				
			||||||
        except Exception:
 | 
					        except Exception:
 | 
				
			||||||
            pass
 | 
					            pass
 | 
				
			||||||
        if row.get("required_permissions"):
 | 
					        if row.get("required_permissions"):
 | 
				
			||||||
@ -608,6 +613,8 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
 | 
				
			|||||||
            ex = row.get("extras") or {}
 | 
					            ex = row.get("extras") or {}
 | 
				
			||||||
            if isinstance(ex, dict) and str(ex.get("category", "")).lower() in {"mod", "moderator", "staff"}:
 | 
					            if isinstance(ex, dict) and str(ex.get("category", "")).lower() in {"mod", "moderator", "staff"}:
 | 
				
			||||||
                row["moderator_only"] = True
 | 
					                row["moderator_only"] = True
 | 
				
			||||||
 | 
					            if isinstance(ex, dict) and str(ex.get("category", "")).lower() in {"admin", "administrator", "owner"}:
 | 
				
			||||||
 | 
					                row["admin_only"] = True
 | 
				
			||||||
        except Exception:
 | 
					        except Exception:
 | 
				
			||||||
            pass
 | 
					            pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -622,8 +629,8 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    all_rows.sort(key=_sort_key)
 | 
					    all_rows.sort(key=_sort_key)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    mods = [r for r in all_rows if 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")]
 | 
					    users = [r for r in all_rows if not (r.get("moderator_only") or r.get("admin_only"))]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
        "title": "ShaiWatcher Commands",
 | 
					        "title": "ShaiWatcher Commands",
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user