Discord REST client, cache and #225 settings (server-26#225 S2)
New app/internal/discord_rest.py: one shared httpx client with the
DiscordBot User-Agent, per-token rate-limit buckets (X-RateLimit-Bucket +
guild), one retry on a 429 of <=3s, global-429 pause per token, and a
process-wide breaker at 4000 401/403/unshared-429 responses per 10 min so
C2 can never get the shared egress IP banned. Tokens are never logged.
inspect_token / refresh_membership / leave_guild / TokenRejected move off
discord_leases onto it. /users/@me/guilds now follows after= pages (it read
one page of 200 and silently dropped the rest); a failed page is unknown,
never a truncated list.
guild_snapshot(token_doc, guild_id): cached, single-flight read of
/guilds/{g}, the bot's own member and /channels for S3's permission check.
401 marks the token invalid; 10004/10007 drop the server from guild_ids;
50001 is confirmed with one membership read and never changes guild_ids
on its own. TTLs: ok 120s, not-member/invalid 600s, unknown 30s.
config.py gets every setting the #225 slices use (perm-check kill switch,
TTLs, breaker budget, OAuth redirect URI, discord_pool_bot_guild_cap=90).
No behaviour change beyond pagination, User-Agent and the breaker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c4e1f9aa61
commit
2b3b875cdc
@@ -429,107 +429,7 @@ async def force_release_token(token_id: str) -> Optional[dict]:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token validation / guild membership (server-26#204 item 5)
|
||||
# Token validation / guild membership / leaving a server moved to
|
||||
# app/internal/discord_rest.py (server-26#225 S2): inspect_token,
|
||||
# refresh_membership, leave_guild, TokenRejected.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DISCORD_API = "https://discord.com/api/v10"
|
||||
|
||||
|
||||
class TokenRejected(Exception):
|
||||
"""Discord answered 401: the string is not a working bot token."""
|
||||
|
||||
|
||||
async def inspect_token(token: str) -> dict:
|
||||
"""Ask Discord who this token is and which servers it is in.
|
||||
|
||||
Returns ``{bot_user_id, bot_name, application_id, guild_ids, valid,
|
||||
validated_at, check_error}``. Raises TokenRejected on a 401. Any other
|
||||
failure (Discord down, rate limit) is reported in ``check_error`` with
|
||||
``valid``/``guild_ids`` = None — unknown, not bad: a token is never
|
||||
rejected because Discord had a bad minute.
|
||||
"""
|
||||
import httpx
|
||||
headers = {"Authorization": f"Bot {token}"}
|
||||
out: dict = {"valid": None, "guild_ids": None, "check_error": None,
|
||||
"validated_at": _now().isoformat()}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, base_url=DISCORD_API, headers=headers) as client:
|
||||
me = await client.get("/users/@me")
|
||||
if me.status_code == 401:
|
||||
raise TokenRejected("Discord rejected this bot token (401).")
|
||||
me.raise_for_status()
|
||||
user = me.json()
|
||||
out["bot_user_id"] = str(user.get("id"))
|
||||
out["bot_name"] = user.get("username")
|
||||
app = await client.get("/oauth2/applications/@me")
|
||||
if app.status_code == 200:
|
||||
out["application_id"] = str(app.json().get("id"))
|
||||
guilds = await client.get("/users/@me/guilds", params={"limit": 200})
|
||||
guilds.raise_for_status()
|
||||
out["guild_ids"] = [str(g["id"]) for g in guilds.json()]
|
||||
out["valid"] = True
|
||||
except TokenRejected:
|
||||
raise
|
||||
except Exception as e: # network / 5xx / 429 — unknown, not invalid
|
||||
out["check_error"] = str(e)[:200]
|
||||
return out
|
||||
|
||||
|
||||
async def refresh_membership(token_ids: list[str]) -> int:
|
||||
"""Re-read guild membership for these tokens. Returns how many changed."""
|
||||
changed = 0
|
||||
for tok_id in token_ids:
|
||||
tok = await fstore.doc_get(TOKENS, tok_id)
|
||||
if not tok or not tok.get("token"):
|
||||
continue
|
||||
try:
|
||||
info = await inspect_token(tok["token"])
|
||||
except TokenRejected as e:
|
||||
await fstore.doc_update(TOKENS, tok_id, {"valid": False, "check_error": str(e),
|
||||
"validated_at": _now().isoformat()})
|
||||
changed += 1
|
||||
continue
|
||||
updates = {k: v for k, v in info.items() if v is not None or k == "check_error"}
|
||||
if updates.get("guild_ids") != tok.get("guild_ids") or updates.get("valid") != tok.get("valid"):
|
||||
changed += 1
|
||||
await fstore.doc_update(TOKENS, tok_id, updates)
|
||||
return changed
|
||||
|
||||
|
||||
async def leave_guild(guild_id: str, org_id: Optional[str]) -> list[dict]:
|
||||
"""Make the radio bots DRB holds tokens for leave one Discord server
|
||||
(Discord REST ``DELETE /users/@me/guilds/{id}``, the bot's own token).
|
||||
|
||||
Only platform-pool bots and ``org_id``'s own BYO bots — never another
|
||||
org's branded bot. Bots whose cached membership says they are not in the
|
||||
server are skipped; unknown membership is tried (404 = not a member).
|
||||
The DRB *server* bot (the discord-bot service) is not in this pool, so it
|
||||
stays in the server until someone kicks it. Returns one row per bot tried:
|
||||
``{token_id, name, left, error}``. Never raises for a Discord failure."""
|
||||
import httpx
|
||||
guild_id = str(guild_id)
|
||||
out: list[dict] = []
|
||||
for tok in await fstore.collection_list(TOKENS):
|
||||
tok_id = tok.get("token_id")
|
||||
if not tok_id or not tok.get("token"):
|
||||
continue
|
||||
if tok.get("org_id") is not None and tok.get("org_id") != org_id:
|
||||
continue
|
||||
members = tok.get("guild_ids")
|
||||
if members is not None and guild_id not in members:
|
||||
continue
|
||||
row = {"token_id": tok_id, "name": tok.get("name") or tok_id, "left": False, "error": None}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, base_url=DISCORD_API,
|
||||
headers={"Authorization": f"Bot {tok['token']}"}) as client:
|
||||
r = await client.delete(f"/users/@me/guilds/{guild_id}")
|
||||
if r.status_code in (200, 204, 404): # 404: Unknown Guild — not a member
|
||||
row["left"] = True
|
||||
else:
|
||||
row["error"] = f"Discord answered {r.status_code}"
|
||||
except Exception as e: # network — report, do not fail the removal
|
||||
row["error"] = str(e)[:200]
|
||||
if row["left"] and members is not None:
|
||||
await fstore.doc_update(TOKENS, tok_id, {"guild_ids": [g for g in members if g != guild_id]})
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user