Files
SREBOT/scripts/diag_entitlements.py
2026-05-31 01:43:19 -07:00

171 lines
6.5 KiB
Python

"""
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 ../SHARED/.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())