""" Diagnostic: query Discord's entitlements API directly and report what it returns right now. Compare against the local entitlements.db cache. Usage (on the server): cd ~/GitHub/SREBOT_MEOW source .venv/bin/activate python scripts/diag_entitlements.py Targeted guild: 1379510072815779961 (bot owner's max-tier server). """ from __future__ import annotations import asyncio import os import sqlite3 import sys import time from pathlib import Path import aiohttp from dotenv import load_dotenv load_dotenv() APPLICATION_ID = "1254679514466877540" TARGET_GUILD = "1379510072815779961" TOKEN = os.environ.get("DISCORD_KEY", "") _storage_env = os.environ.get("STORAGE_VOL_PATH", "").strip() if not _storage_env: raise RuntimeError("STORAGE_VOL_PATH must be set") STORAGE = Path(_storage_env) DB_PATH = STORAGE / "entitlements.db" async def fetch_all_entitlements() -> tuple[list[dict], list[tuple[int, int, str, str]]]: """ Returns (entitlements, page_logs). page_logs: list of (page_num, count, status, after_cursor) """ if not TOKEN: print("ERROR: DISCORD_KEY env var not set", file=sys.stderr) sys.exit(1) headers = {"Authorization": f"Bot {TOKEN}"} url = f"https://discord.com/api/v10/applications/{APPLICATION_ID}/entitlements" params: dict[str, str] = {"exclude_ended": "true", "limit": "100"} all_ents: list[dict] = [] page_logs: list[tuple[int, int, str, str]] = [] page = 0 after: str | None = None timeout = aiohttp.ClientTimeout(total=60) async with aiohttp.ClientSession(timeout=timeout) as session: while True: page += 1 q = dict(params) if after: q["after"] = after t0 = time.monotonic() async with session.get(url, headers=headers, params=q) as resp: elapsed = time.monotonic() - t0 status = f"{resp.status} ({elapsed:.2f}s)" if resp.status != 200: body = await resp.text() page_logs.append((page, 0, status, after or "")) print(f"[!] page {page} failed: {status}\n {body[:300]}") break batch = await resp.json() page_logs.append((page, len(batch), status, after or "")) all_ents.extend(batch) if len(batch) < 100: break # paginate forward by id after = batch[-1]["id"] # safety break if page > 50: print("[!] hit 50-page safety limit, stopping") break return all_ents, page_logs def read_db_state() -> dict[str, int]: if not DB_PATH.exists(): return {"db_missing": 1} conn = sqlite3.connect(str(DB_PATH)) try: cur = conn.cursor() out: dict[str, int] = {} out["guild_entitlements_total"] = cur.execute("SELECT COUNT(*) FROM guild_entitlements").fetchone()[0] out["guild_entitlements_active"] = cur.execute("SELECT COUNT(*) FROM guild_entitlements WHERE status='active'").fetchone()[0] out["manual_entitlements_total"] = cur.execute("SELECT COUNT(*) FROM manual_entitlements").fetchone()[0] out["discord_entitlements_total"] = cur.execute("SELECT COUNT(*) FROM discord_entitlements").fetchone()[0] row = cur.execute("SELECT * FROM discord_entitlements WHERE guild_id=?", (TARGET_GUILD,)).fetchone() out["target_in_discord_table"] = 1 if row else 0 return out finally: conn.close() async def main() -> None: print("=" * 70) print(f"Discord Entitlements Diagnostic — {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}") print("=" * 70) print(f"\n[1] Local DB state ({DB_PATH}):") for k, v in read_db_state().items(): print(f" {k}: {v}") print("\n[2] Discord API: GET /applications/{app_id}/entitlements?exclude_ended=true") ents, pages = await fetch_all_entitlements() print("\n Page log:") for p, n, status, after in pages: print(f" page {p:>2} count={n:>3} status={status:<18} after={after}") print(f"\n Total entitlements returned: {len(ents)}") # Group by guild by_guild: dict[str, list[dict]] = {} for e in ents: gid = e.get("guild_id") if gid: by_guild.setdefault(str(gid), []).append(e) print(f" Unique guild_ids: {len(by_guild)}") print(f" Entitlements with NULL guild_id: {sum(1 for e in ents if not e.get('guild_id'))}") # Status breakdown deleted = sum(1 for e in ents if e.get("deleted")) consumed = sum(1 for e in ents if e.get("consumed")) starts_in_future = sum(1 for e in ents if e.get("starts_at") and e["starts_at"] > time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())) ends_in_past = sum(1 for e in ents if e.get("ends_at") and e["ends_at"] < time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())) print(f" deleted={deleted} consumed={consumed} starts_in_future={starts_in_future} ends_in_past={ends_in_past}") print(f"\n[3] Target guild {TARGET_GUILD}:") target_ents = by_guild.get(TARGET_GUILD, []) if not target_ents: print(f" *** NOT FOUND in Discord API response ***") # Show sample of what we DID get to confirm we're not just paginating wrong sample_guilds = list(by_guild.keys())[:5] print(f" Sample of guild_ids returned: {sample_guilds}") else: for e in target_ents: print(f" id={e.get('id')} sku_id={e.get('sku_id')} type={e.get('type')} " f"deleted={e.get('deleted')} starts_at={e.get('starts_at')} ends_at={e.get('ends_at')}") print("\n[4] SKU breakdown across all returned entitlements:") sku_counts: dict[str, int] = {} for e in ents: sku = str(e.get("sku_id") or "") sku_counts[sku] = sku_counts.get(sku, 0) + 1 for sku, c in sorted(sku_counts.items(), key=lambda x: -x[1]): print(f" {sku}: {c}") print("\n[5] Recommendation:") if not target_ents: print(" Discord is NOT returning the target guild's entitlement.") print(" Either Discord's API is degraded right now, OR the entitlement no longer exists.") print(" Re-run this script in a few minutes to see if results change.") else: print(" Discord IS returning the target guild's entitlement.") print(" The bot's cache must be stale or wholesale-replaced by an empty result.") print(" Force a refresh: restart bot OR call refresh_entitled_guilds(force=True).") if __name__ == "__main__": asyncio.run(main())