171 lines
6.5 KiB
Python
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())
|