Discord: per-bot OAuth "Connect a Discord server" backend (server-26#225 S6)
An org owner links a server by inviting one radio bot through that bot's
own OAuth2 invite (owner decision Q1). The state is a single-use, 10-minute
nonce bound to org + user (stored only as its sha256); the server linked is
the one Discord's code exchange names, never the redirect query.
- routers/discord_oauth.py: GET /discord/oauth/options, POST /start,
POST /complete. 404 while discord_oauth_redirect_uri is empty or no
usable bot has a secret. Bot order: explicit token_id, the node system's
preferred bot, the org's own bot, least-full pool bot under the cap.
- internal/discord_oauth.py: state mint/consume transaction (login-CSRF:
state.uid must be the caller; any presentation burns it), code exchange,
revoke, client-credentials secret check. Through discord_rest's shared
client and breaker; secrets/codes/tokens never logged or in errors.
- discord_guild_link: one link transaction body (_apply_link) and one
settle step that stops a server's streams when the org it acted for
changes (plan finding 3). /drb link redeem, the admin PUT link and OAuth
all use it; the previous org is now read inside the link transaction.
- tokens: GET /tokens is an allowlist (+ oauth_enabled, server_count,
server_cap); PUT/DELETE /tokens/{id}/oauth (admin) checks the secret with
Discord before storing it in bot_oauth/{id}, never echoes it.
- firestore.rules: explicit deny for bot_oauth and discord_oauth_states.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e54191a5d0
commit
db4e326332
@@ -27,6 +27,16 @@ Firestore layout:
|
||||
unlinked_at|None, unlinked_by|None
|
||||
|
||||
Person names are never stored: the redeemer is kept as a Discord user id.
|
||||
|
||||
Every way a server gets linked — ``/drb link code:`` (``redeem_and_settle``),
|
||||
the platform admin's ``PUT /discord/guilds/{id}/link`` and the per-bot OAuth
|
||||
invite (server-26#225 S6) (both ``link_and_settle``) — writes the guild doc
|
||||
through ONE transaction body (``_apply_link``) and then runs ONE settle step
|
||||
(``_settle``): a server that was acting for a different org (in practice the
|
||||
``DISCORD_UNLINKED_GUILD_ORG_ID`` fallback) has its live radio channels
|
||||
stopped, because they were placed on that org's nodes. The org it acted for
|
||||
before is read inside the same transaction as the write, so the stop decision
|
||||
can't race a concurrent link.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -35,6 +45,7 @@ import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.internal import discord_streams as streams
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
CODES = "discord_link_codes"
|
||||
@@ -91,6 +102,63 @@ async def mint_code(org_id: str, uid: str) -> dict:
|
||||
"expires_minutes": CODE_TTL_MINUTES}
|
||||
|
||||
|
||||
# Stop reasons the settle step stamps on the streams it stops.
|
||||
RELINK_STOP_REASON = "relinked"
|
||||
ADMIN_RELINK_STOP_REASON = "server linked to another organization"
|
||||
|
||||
|
||||
def _apply_link(txn, guild_ref, guild: Optional[dict], *, org_id: str, guild_id: str,
|
||||
guild_name: Optional[str], linked_by: str, via: str, now: datetime,
|
||||
allow_move: bool = False, extra: Optional[dict] = None):
|
||||
"""The guild-side half of every link, inside a transaction whose reads are
|
||||
all done. Returns a LinkError (nothing written) or the result dict:
|
||||
``{org_id, already_linked, previous_org_id}`` — ``previous_org_id`` is the
|
||||
org the server acted for before (its link, else the fallback org).
|
||||
|
||||
``allow_move`` is the platform admin's support override; every self-serve
|
||||
path refuses to move a server another org has linked."""
|
||||
current = (guild or {}).get("org_id")
|
||||
if current and current != org_id and not allow_move:
|
||||
return LinkError(409, "linked_elsewhere",
|
||||
"This server is already linked to a different DRB organization. "
|
||||
"That organization has to unlink it first.")
|
||||
iso = now.isoformat()
|
||||
name = (guild_name or "").strip()[:100]
|
||||
txn.set(guild_ref, {
|
||||
"guild_id": guild_id,
|
||||
"org_id": org_id,
|
||||
"guild_name": name or (guild or {}).get("guild_name"),
|
||||
"linked_by": linked_by,
|
||||
"linked_via": via,
|
||||
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
|
||||
"unlinked_at": None,
|
||||
"unlinked_by": None,
|
||||
# A server an admin removed (streams.remove_guild) comes back on link.
|
||||
"removed_at": None,
|
||||
"removed_by": None,
|
||||
**(extra or {}),
|
||||
}, merge=True)
|
||||
return {"org_id": org_id, "already_linked": current == org_id,
|
||||
"previous_org_id": streams.acting_org_of(guild)}
|
||||
|
||||
|
||||
def link_guild_txn(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
|
||||
allow_move: bool = False, extra: Optional[dict] = None, now: Optional[datetime] = None):
|
||||
"""Transaction body that links ``guild_id`` to ``org_id`` directly (no code)."""
|
||||
db = fstore.db
|
||||
when = now or _now()
|
||||
|
||||
def _txn(txn):
|
||||
guild_ref = db.collection(GUILDS).document(guild_id)
|
||||
snap = guild_ref.get(transaction=txn)
|
||||
guild = snap.to_dict() if snap.exists else None
|
||||
out = _apply_link(txn, guild_ref, guild, org_id=org_id, guild_id=guild_id, guild_name=guild_name,
|
||||
linked_by=linked_by, via=via, now=when, allow_move=allow_move, extra=extra)
|
||||
return {"error": out} if isinstance(out, LinkError) else out
|
||||
|
||||
return _txn
|
||||
|
||||
|
||||
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
|
||||
"""Build the transaction body. Every read happens before any write."""
|
||||
db = fstore.db
|
||||
@@ -113,37 +181,20 @@ def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_use
|
||||
return {"error": LinkError(410, "code_expired",
|
||||
"That link code has expired. Generate a new one in DRB → "
|
||||
"Settings → Organization.")}
|
||||
org_id = doc["org_id"]
|
||||
current = (guild or {}).get("org_id")
|
||||
if current and current != org_id:
|
||||
return {"error": LinkError(409, "linked_elsewhere",
|
||||
"This server is already linked to a different DRB organization. "
|
||||
"That organization has to unlink it first.")}
|
||||
|
||||
iso = now.isoformat()
|
||||
txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id,
|
||||
out = _apply_link(txn, guild_ref, guild, org_id=doc["org_id"], guild_id=guild_id,
|
||||
guild_name=guild_name, linked_by=f"discord:{discord_user_id}", via="code", now=now)
|
||||
if isinstance(out, LinkError):
|
||||
return {"error": out} # the code is NOT consumed: its org can use it elsewhere
|
||||
txn.update(code_ref, {"redeemed_at": now.isoformat(), "redeemed_guild_id": guild_id,
|
||||
"redeemed_by_discord_id": discord_user_id})
|
||||
txn.set(guild_ref, {
|
||||
"guild_id": guild_id,
|
||||
"org_id": org_id,
|
||||
"guild_name": (guild_name or "")[:100] or (guild or {}).get("guild_name"),
|
||||
"linked_by": f"discord:{discord_user_id}",
|
||||
"linked_via": "code",
|
||||
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
|
||||
"unlinked_at": None,
|
||||
"unlinked_by": None,
|
||||
# A server an admin removed (streams.remove_guild) comes back on link.
|
||||
"removed_at": None,
|
||||
"removed_by": None,
|
||||
}, merge=True)
|
||||
return {"org_id": org_id, "already_linked": current == org_id,
|
||||
"created_by_uid": doc.get("created_by_uid")}
|
||||
return {**out, "created_by_uid": doc.get("created_by_uid")}
|
||||
|
||||
return _txn
|
||||
|
||||
|
||||
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
|
||||
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal."""
|
||||
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal.
|
||||
Does NOT stop anything — routes call ``redeem_and_settle``."""
|
||||
code = normalize(raw_code)
|
||||
if len(code) != _CODE_LEN:
|
||||
raise LinkError(404, "invalid_code",
|
||||
@@ -155,6 +206,39 @@ async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], d
|
||||
return result
|
||||
|
||||
|
||||
async def _settle(guild_id: str, result: dict, stop_reason: str) -> dict:
|
||||
"""After a link committed: a server that was acting for another org (the
|
||||
fallback org, or — admin override only — another linked org) has its live
|
||||
radio channels stopped; they were placed on that org's nodes, and left
|
||||
running the reconciler would keep re-placing them there
|
||||
(server-26#225 plan finding 3: this lived in two routers before)."""
|
||||
before = result.get("previous_org_id")
|
||||
stopped: list = []
|
||||
if before and before != result["org_id"]:
|
||||
stopped = await streams.stop_guild(str(guild_id), stop_reason)
|
||||
return {**result, "streams_stopped": stopped}
|
||||
|
||||
|
||||
async def link_and_settle(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
|
||||
allow_move: bool = False, extra: Optional[dict] = None,
|
||||
stop_reason: str = RELINK_STOP_REASON) -> dict:
|
||||
"""Link a server to an org directly (admin, OAuth) and settle. Raises
|
||||
LinkError (``linked_elsewhere``) unless ``allow_move``. Returns
|
||||
``{org_id, already_linked, previous_org_id, streams_stopped}``."""
|
||||
result = await fstore.run_transaction(
|
||||
link_guild_txn(org_id, str(guild_id), guild_name, linked_by, via, allow_move=allow_move, extra=extra))
|
||||
if "error" in result:
|
||||
raise result["error"]
|
||||
return await _settle(guild_id, result, stop_reason)
|
||||
|
||||
|
||||
async def redeem_and_settle(raw_code: str, guild_id: str, guild_name: Optional[str],
|
||||
discord_user_id: str) -> dict:
|
||||
"""``/drb link code:`` — redeem, then settle. Raises LinkError."""
|
||||
result = await redeem_code(raw_code, guild_id, guild_name, discord_user_id)
|
||||
return await _settle(guild_id, result, RELINK_STOP_REASON)
|
||||
|
||||
|
||||
async def list_org_guilds(org_id: str) -> list[dict]:
|
||||
return [
|
||||
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Per-bot OAuth "Connect a Discord server" (server-26#225 S6, owner decision Q1).
|
||||
|
||||
An org owner links a Discord server by inviting ONE radio bot through
|
||||
Discord's OAuth2 bot invite. The invite's ``state`` carries a single-use,
|
||||
short-lived nonce bound to the org and the user who started it; Discord
|
||||
redirects back with ``code`` (and ``guild_id``), C2 exchanges the code with
|
||||
THAT bot application's client secret, and the exchange's ``guild`` object —
|
||||
never the query string — says which server to link.
|
||||
|
||||
Secrets:
|
||||
|
||||
bot_oauth/{token_id} server-only (Firestore rules deny all
|
||||
client_secret, enabled, client reads; c2-core's admin SDK only)
|
||||
set_by, set_at, checked_at
|
||||
|
||||
The secret is never on ``bot_tokens`` (GET /tokens returns an allowlist of
|
||||
those fields), never returned by any route, never logged, never put in an
|
||||
error string. Firestore encrypts it at rest like every other document; the
|
||||
codebase has no application-level encryption pattern (bot tokens are
|
||||
stored the same way), so none is invented here.
|
||||
|
||||
States:
|
||||
|
||||
discord_oauth_states/{sha256(nonce)} the nonce itself is never stored
|
||||
org_id, uid, token_id, node_id, return_to, created_at, expires_at,
|
||||
used_at|None, used_by|None
|
||||
|
||||
OAuth is off while ``settings.discord_oauth_redirect_uri`` is empty: every
|
||||
route 404s and the UI hides the button.
|
||||
|
||||
HTTP goes through discord_rest's shared client (User-Agent, transport for
|
||||
tests) and counts 401/403/429 answers against its process-wide breaker, so a
|
||||
wrong secret retried in a loop can't contribute to an IP ban either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_rest
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
OAUTH = "bot_oauth"
|
||||
STATES = "discord_oauth_states"
|
||||
AUTHORIZE_URL = "https://discord.com/oauth2/authorize"
|
||||
# Discord client secrets are 32 url-safe characters today; accept a margin.
|
||||
SECRET_MIN, SECRET_MAX = 16, 128
|
||||
RETURN_TO_MAX = 200
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool((settings.discord_oauth_redirect_uri or "").strip())
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def state_key(nonce: str) -> str:
|
||||
return hashlib.sha256((nonce or "").encode()).hexdigest()
|
||||
|
||||
|
||||
def safe_return_to(value: Optional[str]) -> Optional[str]:
|
||||
"""A same-site path only (``/nodes/abc``), never an absolute or
|
||||
protocol-relative URL — the callback page redirects to it."""
|
||||
v = (value or "").strip()
|
||||
if not v or len(v) > RETURN_TO_MAX or not v.startswith("/") or v.startswith("//") or "\\" in v:
|
||||
return None
|
||||
if any(ord(ch) < 0x20 for ch in v):
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secrets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def enabled_token_ids() -> set[str]:
|
||||
"""Bots whose OAuth secret is set and enabled."""
|
||||
return {d.get("token_id") for d in await fstore.collection_list(OAUTH)
|
||||
if d.get("token_id") and d.get("enabled") and d.get("client_secret")}
|
||||
|
||||
|
||||
async def load_secret(token_id: str) -> Optional[str]:
|
||||
doc = await fstore.doc_get(OAUTH, token_id)
|
||||
if not doc or not doc.get("enabled") or not doc.get("client_secret"):
|
||||
return None
|
||||
return doc["client_secret"]
|
||||
|
||||
|
||||
async def store_secret(token_id: str, client_secret: str, uid: str) -> None:
|
||||
now = _now().isoformat()
|
||||
await fstore.doc_set(OAUTH, token_id, {
|
||||
"token_id": token_id, "client_secret": client_secret, "enabled": True,
|
||||
"set_by": uid, "set_at": now, "checked_at": now,
|
||||
}, merge=False)
|
||||
|
||||
|
||||
async def clear_secret(token_id: str) -> bool:
|
||||
if not await fstore.doc_get(OAUTH, token_id):
|
||||
return False
|
||||
await fstore.doc_delete(OAUTH, token_id)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discord OAuth2 HTTP (client_id + client_secret, basic auth)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _post_form(path: str, data: dict, client_id: str, client_secret: str) -> discord_rest.RestResult:
|
||||
"""POST an application-authenticated form. Never raises for a Discord or
|
||||
network failure; the error string never holds the secret, the code or a
|
||||
token (only Discord's short ``error`` field)."""
|
||||
now = time.monotonic()
|
||||
if discord_rest._breaker.tripped(now):
|
||||
return discord_rest.RestResult(None, error="Discord REST paused (invalid-request budget reached)")
|
||||
try:
|
||||
r = await discord_rest._client().post(path, data=data, auth=(client_id, client_secret))
|
||||
except httpx.HTTPError as e:
|
||||
return discord_rest.RestResult(None, error=type(e).__name__)
|
||||
status = r.status_code
|
||||
if status in (401, 403) or (status == 429 and r.headers.get("x-ratelimit-scope") != "shared"):
|
||||
discord_rest._breaker.record(time.monotonic())
|
||||
body: Any = None
|
||||
try:
|
||||
body = r.json() if r.content else None
|
||||
except ValueError:
|
||||
body = None
|
||||
if 200 <= status < 300:
|
||||
return discord_rest.RestResult(status, body)
|
||||
err = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else None
|
||||
return discord_rest.RestResult(status, None, error=f"Discord answered {status}" + (f" ({err[:40]})" if err else ""))
|
||||
|
||||
|
||||
async def exchange_code(client_id: str, client_secret: str, code: str) -> discord_rest.RestResult:
|
||||
return await _post_form("/oauth2/token", {
|
||||
"grant_type": "authorization_code", "code": code,
|
||||
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
|
||||
}, client_id, client_secret)
|
||||
|
||||
|
||||
async def revoke(client_id: str, client_secret: str, access_token: Optional[str]) -> None:
|
||||
"""Best effort: C2 never uses the user's access token, so it is revoked
|
||||
as soon as the guild has been read from the exchange."""
|
||||
if access_token:
|
||||
await _post_form("/oauth2/token/revoke", {"token": access_token, "token_type_hint": "access_token"},
|
||||
client_id, client_secret)
|
||||
|
||||
|
||||
async def check_secret(client_id: str, client_secret: str) -> discord_rest.RestResult:
|
||||
"""Prove ``client_secret`` belongs to ``client_id`` with a client-
|
||||
credentials grant (scope ``identify`` — the bot owner's own token), then
|
||||
revoke the token it returns. Nothing is stored from the answer."""
|
||||
r = await _post_form("/oauth2/token", {"grant_type": "client_credentials", "scope": "identify"},
|
||||
client_id, client_secret)
|
||||
if r.ok and isinstance(r.data, dict):
|
||||
await revoke(client_id, client_secret, r.data.get("access_token"))
|
||||
return r
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State + authorize URL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def authorize_url(application_id: str, nonce: str) -> str:
|
||||
return AUTHORIZE_URL + "?" + urlencode({
|
||||
"client_id": application_id,
|
||||
"scope": "bot",
|
||||
"permissions": str(leases.RADIO_BOT_PERMISSIONS),
|
||||
"response_type": "code",
|
||||
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
|
||||
"state": nonce,
|
||||
})
|
||||
|
||||
|
||||
async def create_state(*, org_id: str, uid: str, token_id: str, node_id: Optional[str],
|
||||
return_to: Optional[str]) -> tuple[str, datetime]:
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
now = _now()
|
||||
expires_at = now + timedelta(seconds=max(60, settings.discord_oauth_state_ttl_seconds))
|
||||
await fstore.doc_set(STATES, state_key(nonce), {
|
||||
"org_id": org_id, "uid": uid, "token_id": token_id, "node_id": node_id,
|
||||
"return_to": return_to, "created_at": now.isoformat(), "expires_at": expires_at.isoformat(),
|
||||
"used_at": None, "used_by": None,
|
||||
}, merge=False)
|
||||
return nonce, expires_at
|
||||
|
||||
|
||||
class StateError(Exception):
|
||||
def __init__(self, status: int, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.status, self.code, self.message = status, code, message
|
||||
|
||||
|
||||
def consume_state_txn(nonce: str, uid: str, now: datetime):
|
||||
"""Transaction body: the state must exist, be unexpired and unused, and
|
||||
belong to ``uid`` (login-CSRF: someone else's state never links into the
|
||||
caller's org, and the caller's own code can't be replayed by another
|
||||
account). Any presentation of a live state burns it — a failure later in
|
||||
the flow means starting again, never retrying the same state."""
|
||||
db = fstore.db
|
||||
key = state_key(nonce)
|
||||
|
||||
def _txn(txn):
|
||||
ref = db.collection(STATES).document(key)
|
||||
snap = ref.get(transaction=txn)
|
||||
doc = snap.to_dict() if snap.exists else None
|
||||
if not doc:
|
||||
return {"error": StateError(400, "invalid_state",
|
||||
"That Discord link is not valid. Start again from DRB.")}
|
||||
if doc.get("used_at"):
|
||||
return {"error": StateError(409, "state_used",
|
||||
"That Discord link was already used. Start again from DRB.")}
|
||||
txn.update(ref, {"used_at": now.isoformat(), "used_by": uid})
|
||||
try:
|
||||
expired = now > datetime.fromisoformat(doc.get("expires_at") or "")
|
||||
except ValueError:
|
||||
expired = True
|
||||
if expired:
|
||||
return {"error": StateError(410, "state_expired",
|
||||
"That Discord link expired. Start again from DRB.")}
|
||||
if doc.get("uid") != uid:
|
||||
return {"error": StateError(403, "wrong_user",
|
||||
"That Discord link was started by a different DRB account. "
|
||||
"Sign in as that account, or start again.")}
|
||||
return {"state": doc}
|
||||
|
||||
return _txn
|
||||
|
||||
|
||||
async def consume_state(nonce: str, uid: str) -> dict:
|
||||
if not nonce or len(nonce) > 200:
|
||||
raise StateError(400, "invalid_state", "That Discord link is not valid. Start again from DRB.")
|
||||
out = await fstore.run_transaction(consume_state_txn(nonce, uid, _now()))
|
||||
if "error" in out:
|
||||
raise out["error"]
|
||||
return out["state"]
|
||||
@@ -93,6 +93,17 @@ def _parse_ts(value) -> Optional[datetime]:
|
||||
# Guild -> org
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def acting_org_of(link: Optional[dict]) -> Optional[str]:
|
||||
"""``resolve_guild_org`` for an already-read ``discord_guilds`` doc (None =
|
||||
no doc). Pure, so a link transaction can decide from the snapshot it read
|
||||
(discord_guild_link.link_guild_txn, server-26#225 S6)."""
|
||||
if link and link.get("org_id"):
|
||||
return link["org_id"]
|
||||
if link and link.get("unlinked_at"):
|
||||
return None
|
||||
return settings.discord_unlinked_guild_org_id or None
|
||||
|
||||
|
||||
async def resolve_guild_org(guild_id: str) -> Optional[str]:
|
||||
"""The org a Discord server acts for: its ``discord_guilds`` link, else
|
||||
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
|
||||
@@ -100,12 +111,7 @@ async def resolve_guild_org(guild_id: str) -> Optional[str]:
|
||||
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
|
||||
gets no fallback: otherwise unlinking org B's server would quietly hand it
|
||||
to the founding org."""
|
||||
link = await fstore.doc_get(GUILDS, str(guild_id))
|
||||
if link and link.get("org_id"):
|
||||
return link["org_id"]
|
||||
if link and link.get("unlinked_at"):
|
||||
return None
|
||||
return settings.discord_unlinked_guild_org_id or None
|
||||
return acting_org_of(await fstore.doc_get(GUILDS, str(guild_id)))
|
||||
|
||||
|
||||
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]:
|
||||
|
||||
Reference in New Issue
Block a user