feat(discord): self-serve server->org linking with /drb link (#213 item 1)
- c2-core: POST /discord/link-codes (org owner/admin) mints a single-use,
10-minute code; minting revokes the org's older codes. The bot redeems it
via POST /discord/guilds/{id}/link/redeem (service key) inside one
Firestore transaction, so two servers racing for one code cannot both win
and a server linked to org A refuses org B's code. GET/DELETE
/discord/linked-guilds lists/unlinks the caller's org's servers; unlink
stops that server's streams. Rate-limited (mint per user, redeem per
server and per Discord user); every mint/link/refusal/unlink audited.
- resolve_guild_org: an explicitly unlinked server no longer falls back to
DISCORD_UNLINKED_GUILD_ORG_ID. Linking away from the fallback org stops
the streams it had running for that org.
- Discord bot: /drb link code: (Manage Server, enforced at runtime too);
GatedTree answers every other command in an unlinked server with how to
link (autocomplete returns nothing). With the default fallback org set,
nothing is gated — unchanged behaviour.
- Frontend: "Linked Discord servers" card on Settings -> Organization —
generate code with countdown + copy, polls for the new link, unlink.
- 16 tests: single-use, two-server race, one-server/two-org race, expiry,
revoked/malformed codes, wrong-org takeover, idempotent relink, rate
limits, fallback stop, scoped list, unlink without fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dc1df72c86
commit
455c55f5c2
@@ -249,7 +249,11 @@ class Settings(BaseSettings):
|
||||
discord_lease_offline_release_seconds: int = 600
|
||||
# Org a Discord server that was never linked to an org (discord_guilds/{id})
|
||||
# acts for. Default keeps today's single-org deployment working unchanged;
|
||||
# set it to "" to require every server to be linked first.
|
||||
# set it to "" to require every server to be linked first. Do that once
|
||||
# every server that should keep working has been linked (/drb link, or
|
||||
# PUT /discord/guilds/{id}/link) — unlinked servers then get only
|
||||
# /drb link and /help (server-26#213). A server an org explicitly unlinked
|
||||
# never falls back to this org either way.
|
||||
discord_unlinked_guild_org_id: str = "founding"
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Self-serve Discord server -> DRB org linking (server-26#213 item 1).
|
||||
|
||||
Same shape as the account-link codes in routers/links.py (web mints a short
|
||||
code, the Discord bot redeems it), with the gaps that pattern has closed:
|
||||
|
||||
* **Single-use under a race.** Redeem runs in one Firestore transaction that
|
||||
reads the code doc and the guild doc before writing either, so two servers
|
||||
redeeming one code at once cannot both win — the loser's transaction is
|
||||
re-run, sees ``redeemed_at`` set, and is refused.
|
||||
* **Short TTL** (``CODE_TTL_MINUTES``) and **one live code per org**: minting
|
||||
a new code revokes the org's older unredeemed ones.
|
||||
* **Never moves a server between orgs.** A server already linked to org A
|
||||
refuses org B's code (409) until org A unlinks it from its own settings.
|
||||
* An explicitly unlinked server does **not** fall back to
|
||||
``DISCORD_UNLINKED_GUILD_ORG_ID`` (see ``discord_streams.resolve_guild_org``)
|
||||
— otherwise unlinking org B's server would hand it to the founding org.
|
||||
|
||||
Firestore layout:
|
||||
|
||||
discord_link_codes/{CODE} CODE = 10 chars, no dashes, upper-case
|
||||
org_id, created_by_uid, created_at, expires_at,
|
||||
redeemed_at|None, redeemed_guild_id|None, revoked: bool
|
||||
|
||||
discord_guilds/{guild_id} (shared with discord_streams.py)
|
||||
guild_id, org_id|None, guild_name, linked_by, linked_via, linked_at,
|
||||
unlinked_at|None, unlinked_by|None
|
||||
|
||||
Person names are never stored: the redeemer is kept as a Discord user id.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
CODES = "discord_link_codes"
|
||||
GUILDS = "discord_guilds"
|
||||
CODE_TTL_MINUTES = 10
|
||||
# Unambiguous alphabet: no 0/O, 1/I/L. 31^10 ~= 8e14 codes.
|
||||
_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
_CODE_LEN = 10
|
||||
|
||||
|
||||
class LinkError(Exception):
|
||||
def __init__(self, status: int, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.status, self.code, self.message = status, code, message
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def new_code() -> str:
|
||||
return "".join(secrets.choice(_ALPHABET) for _ in range(_CODE_LEN))
|
||||
|
||||
|
||||
def normalize(code: str) -> str:
|
||||
"""Accept what a person pastes: any case, with or without dashes/spaces."""
|
||||
return re.sub(r"[^A-Za-z0-9]", "", code or "").upper()
|
||||
|
||||
|
||||
def display(code: str) -> str:
|
||||
"""RVR7Q4K22X -> RVR-7Q4K-22X (the mockup's grouping, for reading aloud)."""
|
||||
return f"{code[:3]}-{code[3:7]}-{code[7:]}"
|
||||
|
||||
|
||||
async def mint_code(org_id: str, uid: str) -> dict:
|
||||
"""Create a code for ``org_id`` and revoke the org's older unredeemed ones."""
|
||||
for old in await fstore.collection_list(CODES, org_id=org_id):
|
||||
if not old.get("redeemed_at") and not old.get("revoked") and old.get("code"):
|
||||
await fstore.doc_update(CODES, old["code"], {"revoked": True})
|
||||
code = new_code()
|
||||
now = _now()
|
||||
expires_at = now + timedelta(minutes=CODE_TTL_MINUTES)
|
||||
await fstore.doc_set(CODES, code, {
|
||||
"code": code,
|
||||
"org_id": org_id,
|
||||
"created_by_uid": uid,
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"redeemed_at": None,
|
||||
"redeemed_guild_id": None,
|
||||
"revoked": False,
|
||||
}, merge=False)
|
||||
return {"code": display(code), "expires_at": expires_at.isoformat(),
|
||||
"expires_minutes": CODE_TTL_MINUTES}
|
||||
|
||||
|
||||
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
|
||||
|
||||
def _txn(txn):
|
||||
code_ref = db.collection(CODES).document(code)
|
||||
guild_ref = db.collection(GUILDS).document(guild_id)
|
||||
code_snap = code_ref.get(transaction=txn)
|
||||
guild_snap = guild_ref.get(transaction=txn)
|
||||
doc = code_snap.to_dict() if code_snap.exists else None
|
||||
guild = guild_snap.to_dict() if guild_snap.exists else None
|
||||
|
||||
if not doc or doc.get("revoked"):
|
||||
return {"error": LinkError(404, "invalid_code",
|
||||
"That link code is not valid. Generate a new one in DRB → "
|
||||
"Settings → Organization.")}
|
||||
if doc.get("redeemed_at"):
|
||||
return {"error": LinkError(409, "code_used", "That link code has already been used.")}
|
||||
if now > datetime.fromisoformat(doc["expires_at"]):
|
||||
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,
|
||||
"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,
|
||||
}, merge=True)
|
||||
return {"org_id": org_id, "already_linked": current == org_id,
|
||||
"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."""
|
||||
code = normalize(raw_code)
|
||||
if len(code) != _CODE_LEN:
|
||||
raise LinkError(404, "invalid_code",
|
||||
"That link code is not valid. Generate a new one in DRB → Settings → Organization.")
|
||||
result = await fstore.run_transaction(
|
||||
_redeem_txn(code, str(guild_id), guild_name, str(discord_user_id), _now()))
|
||||
if "error" in result:
|
||||
raise result["error"]
|
||||
return result
|
||||
|
||||
|
||||
async def list_org_guilds(org_id: str) -> list[dict]:
|
||||
return [
|
||||
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
|
||||
"linked_at": g.get("linked_at"), "linked_via": g.get("linked_via") or "admin"}
|
||||
for g in await fstore.collection_list(GUILDS, org_id=org_id)
|
||||
]
|
||||
|
||||
|
||||
async def unlink(org_id: str, guild_id: str, uid: str) -> None:
|
||||
guild = await fstore.doc_get(GUILDS, guild_id)
|
||||
if not guild or guild.get("org_id") != org_id:
|
||||
raise LinkError(404, "not_linked", "That Discord server is not linked to this organization.")
|
||||
await fstore.doc_set(GUILDS, guild_id, {
|
||||
"org_id": None, "unlinked_at": _now().isoformat(), "unlinked_by": uid,
|
||||
}, merge=True)
|
||||
@@ -75,10 +75,16 @@ def _parse_ts(value) -> Optional[datetime]:
|
||||
|
||||
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)."""
|
||||
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.internal.auth import (
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
|
||||
from app.routers import discord as discord_router
|
||||
from app.routers import discord_link as discord_link_router
|
||||
from app.internal import dynsec
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
@@ -158,6 +159,7 @@ app.include_router(media.router)
|
||||
# auth is per-endpoint: bot routes take the service key (or a platform admin);
|
||||
# the token-fetch route takes the calling node's own API key.
|
||||
app.include_router(discord_router.router)
|
||||
app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
|
||||
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
|
||||
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
|
||||
# is archived (no CVE patches) and was rejected for an internet-facing
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Self-serve Discord server -> org linking (server-26#213 item 1).
|
||||
|
||||
POST /discord/link-codes org owner / platform admin — mint a code
|
||||
GET /discord/linked-guilds org owner / platform admin — this org's servers
|
||||
DELETE /discord/linked-guilds/{guild_id} org owner / platform admin — unlink one
|
||||
POST /discord/guilds/{guild_id}/link/redeem Discord bot (service key) — /drb link code:
|
||||
|
||||
Platform admins may pass ?org_id= to act on another org (resolve_org_scope),
|
||||
same as the rest of the org-scoped API. The admin-only
|
||||
``PUT /discord/guilds/{id}/link`` in routers/discord.py stays for support.
|
||||
|
||||
Every mint, link, refusal and unlink is written to audit_log. Rate limits are
|
||||
the in-process sliding-window limiter from internal/auth.py (single c2-core
|
||||
instance; not shared across replicas).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_guild_link as glink
|
||||
from app.internal import discord_streams as streams
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.audit import write_audit
|
||||
from app.internal.auth import (
|
||||
_RateLimiter, describe_actor, require_org_owner_token, require_service_key, resolve_org_scope,
|
||||
)
|
||||
from app.internal.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/discord", tags=["discord"])
|
||||
|
||||
# Minting: 5 codes per user per 10 min — each mint revokes the previous one,
|
||||
# so this only stops a runaway client.
|
||||
mint_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||
# Redeeming: 5 attempts per server and 10 per Discord user per 10 min. With a
|
||||
# 10-character code over 31 symbols and a 10-minute TTL, guessing is hopeless
|
||||
# at this rate even across many throwaway servers.
|
||||
redeem_guild_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||
redeem_user_limiter = _RateLimiter(max_calls=10, window_seconds=600)
|
||||
|
||||
|
||||
def _caller_org(decoded: dict, org_id: Optional[str]) -> str:
|
||||
return resolve_org_scope(decoded, org_id)
|
||||
|
||||
|
||||
@router.post("/link-codes")
|
||||
async def create_link_code(org_id: Optional[str] = None, decoded: dict = Depends(require_org_owner_token)):
|
||||
"""Mint a single-use code an org owner pastes into ``/drb link code:``."""
|
||||
scope = _caller_org(decoded, org_id)
|
||||
uid, email = describe_actor(decoded)
|
||||
mint_limiter.check(f"{uid}:discord_link_code")
|
||||
out = await glink.mint_code(scope, uid)
|
||||
await write_audit(uid, email, "discord.link_code.create",
|
||||
details={"org_id": scope, "expires_at": out["expires_at"]})
|
||||
logger.info(f"Discord link code minted for org={scope!r} by uid={uid}")
|
||||
return {**out, "command": f"/drb link code:{out['code']}"}
|
||||
|
||||
|
||||
@router.get("/linked-guilds")
|
||||
async def linked_guilds(org_id: Optional[str] = None, decoded: dict = Depends(require_org_owner_token)):
|
||||
scope = _caller_org(decoded, org_id)
|
||||
return {
|
||||
"guilds": await glink.list_org_guilds(scope),
|
||||
# False while DISCORD_UNLINKED_GUILD_ORG_ID is set: unlinked servers
|
||||
# then still act for that org instead of seeing only /drb link + /help.
|
||||
"unlinked_guilds_restricted": not settings.discord_unlinked_guild_org_id,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/linked-guilds/{guild_id}")
|
||||
async def unlink_guild(guild_id: str, org_id: Optional[str] = None,
|
||||
decoded: dict = Depends(require_org_owner_token)):
|
||||
scope = _caller_org(decoded, org_id)
|
||||
uid, email = describe_actor(decoded)
|
||||
try:
|
||||
await glink.unlink(scope, guild_id, uid)
|
||||
except glink.LinkError as e:
|
||||
raise HTTPException(e.status, e.message)
|
||||
# Its radio channels were playing for this org; they go with the link.
|
||||
stopped = await streams.stop_guild(guild_id, "unlinked")
|
||||
await write_audit(uid, email, "discord.guild.unlink",
|
||||
details={"org_id": scope, "guild_id": guild_id, "streams_stopped": len(stopped)})
|
||||
return {"ok": True, "guild_id": guild_id, "streams_stopped": len(stopped)}
|
||||
|
||||
|
||||
class RedeemBody(BaseModel):
|
||||
code: str
|
||||
discord_user_id: str
|
||||
guild_name: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/guilds/{guild_id}/link/redeem")
|
||||
async def redeem_link_code(guild_id: str, body: RedeemBody, principal: dict = Depends(require_service_key)):
|
||||
"""The Discord bot redeems a code for the server it was typed in. The bot
|
||||
has already checked the member holds Manage Server."""
|
||||
redeem_guild_limiter.check(f"guild:{guild_id}")
|
||||
redeem_user_limiter.check(f"user:{body.discord_user_id}")
|
||||
actor = f"discord:{body.discord_user_id}"
|
||||
before = await streams.resolve_guild_org(guild_id)
|
||||
try:
|
||||
result = await glink.redeem_code(body.code, guild_id, body.guild_name, body.discord_user_id)
|
||||
except glink.LinkError as e:
|
||||
await write_audit(actor, "", "discord.guild.link_refused",
|
||||
details={"guild_id": guild_id, "reason": e.code})
|
||||
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
|
||||
|
||||
org_id = result["org_id"]
|
||||
stopped: list = []
|
||||
if before and before != org_id:
|
||||
# The server was acting for DISCORD_UNLINKED_GUILD_ORG_ID; anything it
|
||||
# had playing belongs to that org, not the one it just linked to.
|
||||
stopped = await streams.stop_guild(guild_id, "relinked")
|
||||
await write_audit(actor, "", "discord.guild.link", details={
|
||||
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
|
||||
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
|
||||
})
|
||||
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
|
||||
org = await fstore.doc_get("organizations", org_id)
|
||||
return {"ok": True, "guild_id": guild_id, "org_id": org_id,
|
||||
"org_name": (org or {}).get("name"), "already_linked": result["already_linked"]}
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Self-serve Discord server -> org linking (server-26#213 item 1).
|
||||
|
||||
Race tests run two real transactions against tests/fake_firestore.py, held
|
||||
together at the commit point, so the interleaving the single-use guarantee
|
||||
has to survive actually happens.
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import auth as auth_mod
|
||||
from app.internal import discord_guild_link as glink
|
||||
from app.internal import discord_streams as streams
|
||||
from app.routers import discord_link as router
|
||||
from tests.fake_firestore import FakeFirestore
|
||||
|
||||
OWNER_A = {"uid": "owner-a", "email": "a@example.test", "org_id": "org-a", "org_role": "owner"}
|
||||
OWNER_B = {"uid": "owner-b", "email": "b@example.test", "org_id": "org-b", "org_role": "owner"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fs(monkeypatch):
|
||||
fake = FakeFirestore.install(monkeypatch)
|
||||
fake.put("organizations", "org-a", {"org_id": "org-a", "name": "Riverton Fire Co. 7"})
|
||||
fake.put("organizations", "org-b", {"org_id": "org-b", "name": "Harbor Towing"})
|
||||
for lim in (router.mint_limiter, router.redeem_guild_limiter, router.redeem_user_limiter):
|
||||
lim._log.clear()
|
||||
stopped: list = []
|
||||
|
||||
async def _stop_guild(guild_id, reason, channel_id=None):
|
||||
stopped.append((guild_id, reason))
|
||||
return [f"{guild_id}:c1"]
|
||||
monkeypatch.setattr(streams, "stop_guild", _stop_guild)
|
||||
fake.stopped = stopped
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||
return fake
|
||||
|
||||
|
||||
def _audit(fake, action):
|
||||
return [e for e in (fake.data.get("audit_log") or {}).values() if e["action"] == action]
|
||||
|
||||
|
||||
async def _mint(decoded=OWNER_A):
|
||||
return await router.create_link_code(org_id=None, decoded=decoded)
|
||||
|
||||
|
||||
async def _redeem(code, guild="g1", user="u1", name="Station 7"):
|
||||
return await router.redeem_link_code(
|
||||
guild, router.RedeemBody(code=code, discord_user_id=user, guild_name=name), principal={"service": True})
|
||||
|
||||
|
||||
def _status(resp):
|
||||
return getattr(resp, "status_code", 200)
|
||||
|
||||
|
||||
def _hold_both_at_commit(fake):
|
||||
barrier = threading.Barrier(2, timeout=5)
|
||||
|
||||
def _hook(attempt):
|
||||
if attempt == 0:
|
||||
barrier.wait()
|
||||
fake.before_commit = _hook
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_mint_returns_display_code_and_audits(fs):
|
||||
out = await _mint()
|
||||
assert len(out["code"]) == 12 and out["code"][3] == "-" and out["code"][8] == "-"
|
||||
assert out["expires_minutes"] == glink.CODE_TTL_MINUTES
|
||||
assert out["command"] == f"/drb link code:{out['code']}"
|
||||
doc = fs.get(glink.CODES, glink.normalize(out["code"]))
|
||||
assert doc["org_id"] == "org-a" and doc["redeemed_at"] is None and doc["revoked"] is False
|
||||
assert len(_audit(fs, "discord.link_code.create")) == 1
|
||||
|
||||
|
||||
async def test_minting_again_revokes_the_previous_code(fs):
|
||||
first = await _mint()
|
||||
second = await _mint()
|
||||
assert fs.get(glink.CODES, glink.normalize(first["code"]))["revoked"] is True
|
||||
resp = await _redeem(first["code"])
|
||||
assert _status(resp) == 404
|
||||
assert _status(await _redeem(second["code"])) == 200
|
||||
|
||||
|
||||
async def test_mint_rate_limited_per_user(fs):
|
||||
for _ in range(5):
|
||||
await _mint()
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await _mint()
|
||||
assert e.value.status_code == 429
|
||||
|
||||
|
||||
async def test_member_cannot_mint(fs, monkeypatch):
|
||||
member = {**OWNER_A, "org_role": "member"}
|
||||
monkeypatch.setattr(auth_mod.firebase_auth, "verify_id_token", lambda _t: member)
|
||||
|
||||
class _Cred:
|
||||
credentials = "tok"
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await auth_mod.require_org_owner_token(_Cred())
|
||||
assert e.value.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redeem
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_redeem_links_the_guild_and_is_single_use(fs):
|
||||
out = await _mint()
|
||||
resp = await _redeem(out["code"].lower()) # pasted in lower case, dashes kept
|
||||
assert resp["org_id"] == "org-a" and resp["org_name"] == "Riverton Fire Co. 7"
|
||||
assert await streams.resolve_guild_org("g1") == "org-a"
|
||||
g = fs.get(streams.GUILDS, "g1")
|
||||
assert g["linked_by"] == "discord:u1" and g["linked_via"] == "code" and g["guild_name"] == "Station 7"
|
||||
code_doc = fs.get(glink.CODES, glink.normalize(out["code"]))
|
||||
assert code_doc["redeemed_guild_id"] == "g1"
|
||||
assert len(_audit(fs, "discord.guild.link")) == 1
|
||||
|
||||
again = await _redeem(out["code"], guild="g2", user="u2")
|
||||
assert _status(again) == 409
|
||||
assert fs.get(streams.GUILDS, "g2") is None
|
||||
assert [e["details"]["reason"] for e in _audit(fs, "discord.guild.link_refused")] == ["code_used"]
|
||||
|
||||
|
||||
async def test_two_servers_racing_for_one_code_only_one_wins(fs):
|
||||
out = await _mint()
|
||||
_hold_both_at_commit(fs)
|
||||
r1, r2 = await asyncio.gather(_redeem(out["code"], guild="g1", user="u1"),
|
||||
_redeem(out["code"], guild="g2", user="u2"))
|
||||
assert fs.retries >= 1 # the race really happened
|
||||
assert sorted([_status(r1), _status(r2)]) == [200, 409]
|
||||
linked = [g for g in ("g1", "g2") if (fs.get(streams.GUILDS, g) or {}).get("org_id")]
|
||||
assert len(linked) == 1
|
||||
assert fs.get(glink.CODES, glink.normalize(out["code"]))["redeemed_guild_id"] == linked[0]
|
||||
|
||||
|
||||
async def test_one_server_racing_two_orgs_codes_ends_on_exactly_one(fs):
|
||||
a = await _mint(OWNER_A)
|
||||
b = await _mint(OWNER_B)
|
||||
_hold_both_at_commit(fs)
|
||||
r1, r2 = await asyncio.gather(_redeem(a["code"], user="u1"), _redeem(b["code"], user="u2"))
|
||||
assert sorted([_status(r1), _status(r2)]) == [200, 409]
|
||||
winner = "org-a" if _status(r1) == 200 else "org-b"
|
||||
loser_code = b["code"] if winner == "org-a" else a["code"]
|
||||
assert fs.get(streams.GUILDS, "g1")["org_id"] == winner
|
||||
# The refused code was not consumed — its org can still use it elsewhere.
|
||||
assert fs.get(glink.CODES, glink.normalize(loser_code))["redeemed_at"] is None
|
||||
|
||||
|
||||
async def test_expired_code_is_refused(fs):
|
||||
out = await _mint()
|
||||
key = glink.normalize(out["code"])
|
||||
past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
|
||||
fs.put(glink.CODES, key, {**fs.get(glink.CODES, key), "expires_at": past})
|
||||
resp = await _redeem(out["code"])
|
||||
assert _status(resp) == 410
|
||||
assert fs.get(streams.GUILDS, "g1") is None
|
||||
assert fs.get(glink.CODES, key)["redeemed_at"] is None
|
||||
|
||||
|
||||
async def test_unknown_or_malformed_code_is_refused(fs):
|
||||
assert _status(await _redeem("NOPE")) == 404
|
||||
assert _status(await _redeem("ABC-DEFG-HJK")) == 404
|
||||
|
||||
|
||||
async def test_wrong_org_cannot_take_over_a_linked_server(fs):
|
||||
a = await _mint(OWNER_A)
|
||||
assert _status(await _redeem(a["code"])) == 200
|
||||
b = await _mint(OWNER_B)
|
||||
resp = await _redeem(b["code"], user="u9")
|
||||
assert _status(resp) == 409
|
||||
assert fs.get(streams.GUILDS, "g1")["org_id"] == "org-a"
|
||||
assert fs.get(glink.CODES, glink.normalize(b["code"]))["redeemed_at"] is None
|
||||
assert fs.stopped == []
|
||||
|
||||
|
||||
async def test_same_org_relink_is_idempotent(fs):
|
||||
first = await _mint()
|
||||
await _redeem(first["code"])
|
||||
linked_at = fs.get(streams.GUILDS, "g1")["linked_at"]
|
||||
second = await _mint()
|
||||
resp = await _redeem(second["code"])
|
||||
assert resp["already_linked"] is True
|
||||
assert fs.get(streams.GUILDS, "g1")["linked_at"] == linked_at
|
||||
|
||||
|
||||
async def test_redeem_rate_limited_per_server(fs):
|
||||
for _ in range(5):
|
||||
await _redeem("WRONGCODE1", user="u-many")
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await _redeem("WRONGCODE1", user="u-other")
|
||||
assert e.value.status_code == 429
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DISCORD_UNLINKED_GUILD_ORG_ID back-compat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_linking_away_from_the_fallback_org_stops_its_streams(fs, monkeypatch):
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
|
||||
assert await streams.resolve_guild_org("g1") == "founding"
|
||||
out = await _mint()
|
||||
await _redeem(out["code"])
|
||||
assert fs.stopped == [("g1", "relinked")]
|
||||
assert await streams.resolve_guild_org("g1") == "org-a"
|
||||
|
||||
|
||||
async def test_linking_to_the_fallback_org_itself_stops_nothing(fs, monkeypatch):
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "org-a")
|
||||
out = await _mint()
|
||||
await _redeem(out["code"])
|
||||
assert fs.stopped == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List / unlink
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_list_is_scoped_to_the_callers_org(fs, monkeypatch):
|
||||
await _redeem((await _mint(OWNER_A))["code"], guild="g1")
|
||||
await _redeem((await _mint(OWNER_B))["code"], guild="g2", user="u2")
|
||||
out = await router.linked_guilds(org_id="org-b", decoded=OWNER_A) # non-admin override ignored
|
||||
assert [g["guild_id"] for g in out["guilds"]] == ["g1"]
|
||||
assert out["unlinked_guilds_restricted"] is True
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
|
||||
assert (await router.linked_guilds(org_id=None, decoded=OWNER_A))["unlinked_guilds_restricted"] is False
|
||||
|
||||
|
||||
async def test_unlink_stops_streams_and_never_falls_back(fs, monkeypatch):
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
|
||||
await _redeem((await _mint())["code"])
|
||||
fs.stopped.clear()
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await router.unlink_guild("g1", org_id=None, decoded=OWNER_B) # not B's server
|
||||
assert e.value.status_code == 404
|
||||
out = await router.unlink_guild("g1", org_id=None, decoded=OWNER_A)
|
||||
assert out["ok"] and fs.stopped == [("g1", "unlinked")]
|
||||
assert await streams.resolve_guild_org("g1") is None # NOT "founding"
|
||||
assert len(_audit(fs, "discord.guild.unlink")) == 1
|
||||
# ...and it can be linked again, by any org, with a fresh code.
|
||||
resp = await _redeem((await _mint(OWNER_B))["code"])
|
||||
assert resp["org_id"] == "org-b"
|
||||
assert await streams.resolve_guild_org("g1") == "org-b"
|
||||
@@ -10,6 +10,7 @@ import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { DiscordServersCard } from "@/components/DiscordServersCard";
|
||||
|
||||
function StatTile({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
@@ -118,6 +119,8 @@ export default function OrganizationSettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<DiscordServersCard canEdit={canEdit} isAdmin={isAdmin} />
|
||||
|
||||
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
|
||||
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
// Settings → Organization → "Linked Discord servers" (server-26#213 item 1).
|
||||
// Matches design-canvas/discord-system-mockup.html's Servers panel, minus the
|
||||
// plan / incident-forum / roles / limits columns: none of those exist
|
||||
// server-side yet, and a column with nothing behind it is a dead surface.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
type Guild = { guild_id: string; guild_name: string | null; linked_at: string | null; linked_via: string };
|
||||
type Code = { code: string; expires_at: string; command: string };
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
function shortId(id: string) {
|
||||
return id.length > 8 ? `${id.slice(0, 2)}…${id.slice(-4)}` : id;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function fmtLeft(ms: number) {
|
||||
const s = Math.max(0, Math.ceil(ms / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isAdmin: boolean }) {
|
||||
const [guilds, setGuilds] = useState<Guild[] | null>(null);
|
||||
const [restricted, setRestricted] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [code, setCode] = useState<Code | null>(null);
|
||||
const [justLinked, setJustLinked] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const known = useRef<Set<string> | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await c2api.listLinkedDiscordGuilds();
|
||||
setRestricted(res.unlinked_guilds_restricted);
|
||||
setLoadError(null);
|
||||
setGuilds(res.guilds);
|
||||
return res.guilds;
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : "Could not load Discord servers.");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEdit) return;
|
||||
load().then((g) => { if (g) known.current = new Set(g.map((x) => x.guild_id)); });
|
||||
}, [canEdit, load]);
|
||||
|
||||
const msLeft = code ? new Date(code.expires_at).getTime() - now : 0;
|
||||
const live = !!code && msLeft > 0;
|
||||
|
||||
// While a code is live: tick the countdown, and poll the list so the page
|
||||
// notices the moment someone runs /drb link in Discord.
|
||||
useEffect(() => {
|
||||
if (!live) return;
|
||||
const tick = setInterval(() => setNow(Date.now()), 1000);
|
||||
const poll = setInterval(async () => {
|
||||
const g = await load();
|
||||
if (!g) return;
|
||||
const prev = known.current;
|
||||
const added = prev ? g.find((x) => !prev.has(x.guild_id)) : undefined;
|
||||
known.current = new Set(g.map((x) => x.guild_id));
|
||||
if (added) {
|
||||
setCode(null);
|
||||
setJustLinked(added.guild_name || `guild ${shortId(added.guild_id)}`);
|
||||
}
|
||||
}, POLL_MS);
|
||||
return () => { clearInterval(tick); clearInterval(poll); };
|
||||
}, [live, load]);
|
||||
|
||||
async function generate() {
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
setJustLinked(null);
|
||||
setCopied(false);
|
||||
try {
|
||||
const res = await c2api.createDiscordLinkCode();
|
||||
setNow(Date.now());
|
||||
setCode(res);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Could not generate a code.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function unlink(g: Guild) {
|
||||
const name = g.guild_name || `guild ${shortId(g.guild_id)}`;
|
||||
if (!window.confirm(`Unlink ${name}? Its radio channels stop, and it can't use DRB until it is linked again.`)) {
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
try {
|
||||
await c2api.unlinkDiscordGuild(g.guild_id);
|
||||
const next = await load();
|
||||
if (next) known.current = new Set(next.map((x) => x.guild_id));
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Could not unlink that server.");
|
||||
}
|
||||
}
|
||||
|
||||
function copy() {
|
||||
if (!code) return;
|
||||
navigator.clipboard?.writeText(code.command).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<div className="p-5 pb-0">
|
||||
<CardHeader
|
||||
title="Linked Discord servers"
|
||||
subtitle={<>A link code is run as <span className="font-mono">/drb link</span> by someone with Manage Server in that Discord server.</>}
|
||||
action={
|
||||
<Button size="sm" onClick={generate} disabled={!canEdit || busy}
|
||||
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
|
||||
{busy ? "Generating…" : live ? "New code" : "Generate link code"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(code || justLinked || actionError) && (
|
||||
<div className="mx-5 mb-4 rounded-lg border border-line bg-raised p-4 text-sm">
|
||||
{actionError && <p className="text-sev-major text-xs">{actionError}</p>}
|
||||
{justLinked && <p className="text-ink">Linked <b>{justLinked}</b>. Its <span className="font-mono">/join</span>, <span className="font-mono">/leave</span> and <span className="font-mono">/status</span> now act for this organization.</p>}
|
||||
{code && live && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="font-mono text-xl font-bold tracking-wider text-ink">{code.code}</span>
|
||||
<span className="text-xs text-ink-muted">single use · expires in {fmtLeft(msLeft)}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="font-mono text-xs text-ink-2 bg-surface border border-line rounded px-2 py-1 break-all">{code.command}</code>
|
||||
<Button size="sm" variant="secondary" onClick={copy}>{copied ? "Copied" : "Copy command"}</Button>
|
||||
</div>
|
||||
<p className="text-xs text-ink-muted">
|
||||
Run it in the Discord server you want to link. That server can then use this organization's
|
||||
systems and radio channels — nothing from any other organization. Generating a new code cancels this one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{code && !live && <p className="text-xs text-ink-muted">That code expired. Generate a new one.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-ink-muted uppercase tracking-wider border-y border-line">
|
||||
<th className="px-5 py-2.5 text-left font-medium">Server</th>
|
||||
<th className="px-5 py-2.5 text-left font-medium hidden sm:table-cell">Linked</th>
|
||||
<th className="px-5 py-2.5 text-left font-medium">Status</th>
|
||||
<th className="px-5 py-2.5 w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{guilds === null && !loadError && (
|
||||
<tr><td colSpan={4} className="px-5 py-4 text-xs text-ink-muted">Loading…</td></tr>
|
||||
)}
|
||||
{loadError && (
|
||||
<tr><td colSpan={4} className="px-5 py-4 text-xs text-sev-major">{loadError}</td></tr>
|
||||
)}
|
||||
{guilds?.length === 0 && (
|
||||
<tr><td colSpan={4} className="px-5 py-4 text-xs text-ink-muted">No Discord servers linked yet.</td></tr>
|
||||
)}
|
||||
{guilds?.map((g) => (
|
||||
<tr key={g.guild_id} className="border-b border-line last:border-0">
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-ink font-medium">{g.guild_name || "Unnamed server"}</div>
|
||||
<div className="text-xs text-ink-muted font-mono" title={g.guild_id}>guild {shortId(g.guild_id)}</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-ink-2 hidden sm:table-cell">{fmtDate(g.linked_at)}</td>
|
||||
<td className="px-5 py-3"><Badge tone="success">Linked</Badge></td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
{canEdit && (
|
||||
<button onClick={() => unlink(g)} className="text-xs text-sev-major hover:brightness-110 transition-colors">
|
||||
Unlink
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
|
||||
{restricted ? (
|
||||
<>Unlinked servers see only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
|
||||
) : isAdmin ? (
|
||||
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
|
||||
) : null}
|
||||
Every command and autocomplete is scoped to the linked organization — no server sees another
|
||||
organization's nodes, systems or bots.
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -384,6 +384,24 @@ export const c2api = {
|
||||
revokeEnrollmentToken: (tokenId: string) =>
|
||||
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
|
||||
|
||||
// Discord server ↔ org linking (server-26#213). Owner mints a single-use
|
||||
// code; someone with Manage Server redeems it with /drb link in Discord.
|
||||
createDiscordLinkCode: () =>
|
||||
request<{ code: string; expires_at: string; expires_minutes: number; command: string }>(
|
||||
"/discord/link-codes",
|
||||
{ method: "POST" }
|
||||
),
|
||||
listLinkedDiscordGuilds: () =>
|
||||
request<{
|
||||
guilds: { guild_id: string; guild_name: string | null; linked_at: string | null; linked_via: string }[];
|
||||
unlinked_guilds_restricted: boolean;
|
||||
}>("/discord/linked-guilds"),
|
||||
unlinkDiscordGuild: (guildId: string) =>
|
||||
request<{ ok: boolean; guild_id: string; streams_stopped: number }>(
|
||||
`/discord/linked-guilds/${encodeURIComponent(guildId)}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
|
||||
// 511NY cameras/events inside a map bbox (server-26#183)
|
||||
get511: (bbox: { south: number; west: number; north: number; east: number }, layers: string) =>
|
||||
request<Ny511Response>(`/traffic/511?${new URLSearchParams({
|
||||
|
||||
@@ -2,16 +2,19 @@ import asyncio
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from app.config import settings
|
||||
from app.internal.guild_gate import GatedTree
|
||||
from app.internal.logger import logger
|
||||
|
||||
|
||||
class DRBBot(commands.Bot):
|
||||
def __init__(self):
|
||||
intents = discord.Intents.default()
|
||||
super().__init__(command_prefix="!", intents=intents)
|
||||
# GatedTree: unlinked servers get only /drb link and /help (server-26#213).
|
||||
super().__init__(command_prefix="!", intents=intents, tree_cls=GatedTree)
|
||||
|
||||
async def setup_hook(self):
|
||||
await self.load_extension("app.commands.radio")
|
||||
await self.load_extension("app.commands.drb")
|
||||
await self.load_extension("app.commands.trips")
|
||||
|
||||
if settings.dev_guild_id:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""/drb — server-level DRB setup. Today: /drb link (server-26#213 item 1)."""
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
from app.internal import guild_gate
|
||||
from app.internal.c2_client import c2
|
||||
|
||||
|
||||
class DRBCommands(commands.Cog):
|
||||
drb = app_commands.Group(
|
||||
name="drb",
|
||||
description="Set up DRB in this server.",
|
||||
guild_only=True,
|
||||
default_permissions=discord.Permissions(manage_guild=True),
|
||||
)
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@drb.command(name="link", description="Link this server to your DRB organization.")
|
||||
@app_commands.describe(code="The code from DRB → Settings → Organization → Discord servers.")
|
||||
async def link(self, interaction: discord.Interaction, code: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
# default_permissions is only the default — a server admin can grant
|
||||
# the command to anyone under Integrations. Linking decides which
|
||||
# organization's radio this whole server gets, so hold the line here.
|
||||
perms = getattr(interaction.user, "guild_permissions", None)
|
||||
if not perms or not perms.manage_guild:
|
||||
await interaction.followup.send("Only members with **Manage Server** can link this server.",
|
||||
ephemeral=True)
|
||||
return
|
||||
ok, body = await c2.redeem_guild_link(
|
||||
interaction.guild_id, code, str(interaction.user.id),
|
||||
interaction.guild.name if interaction.guild else None,
|
||||
)
|
||||
guild_gate.forget(interaction.guild_id)
|
||||
radio = self.bot.get_cog("RadioCommands")
|
||||
if radio is not None:
|
||||
radio._overview.pop(interaction.guild_id, None)
|
||||
if not ok:
|
||||
await interaction.followup.send(body.get("detail") or "DRB could not link this server.",
|
||||
ephemeral=True)
|
||||
return
|
||||
org = body.get("org_name") or "your DRB organization"
|
||||
if body.get("already_linked"):
|
||||
text = f"This server was already linked to **{org}**. Nothing changed."
|
||||
else:
|
||||
text = (f"This server is now linked to **{org}**.\n"
|
||||
"`/join`, `/leave` and `/status` now act for that organization only.")
|
||||
await interaction.followup.send(text, ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(DRBCommands(bot))
|
||||
@@ -226,6 +226,15 @@ class RadioCommands(commands.Cog):
|
||||
@app_commands.command(name="help", description="How to use this bot.")
|
||||
async def help(self, interaction: discord.Interaction):
|
||||
embed = discord.Embed(title="DRB Radio Bot — Help", color=0x5865f2)
|
||||
embed.add_field(
|
||||
name="/drb link `code`",
|
||||
value=(
|
||||
"Link this server to your DRB organization — needed before anything below works. "
|
||||
"An organization owner gets the code in DRB under Settings → Organization → Discord servers; "
|
||||
"someone with Manage Server runs this."
|
||||
),
|
||||
inline=False,
|
||||
)
|
||||
embed.add_field(
|
||||
name="/join `system` [`bot`] [`persistent`]",
|
||||
value=(
|
||||
|
||||
@@ -68,6 +68,28 @@ class C2Client:
|
||||
logger.error(f"C2 stop_guild failed: {e}")
|
||||
return None
|
||||
|
||||
async def redeem_guild_link(self, guild_id: int | str, code: str, discord_user_id: str,
|
||||
guild_name: Optional[str]) -> tuple[bool, dict]:
|
||||
"""/drb link code: (server-26#213). Returns (ok, body); a refusal body
|
||||
carries C2's own ``detail`` text, which is written for the end user."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/discord/guilds/{guild_id}/link/redeem",
|
||||
json={"code": code, "discord_user_id": discord_user_id, "guild_name": guild_name},
|
||||
headers=self._headers(),
|
||||
)
|
||||
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
||||
if r.status_code == 429:
|
||||
return False, {"detail": "Too many link attempts from this server. Wait ten minutes "
|
||||
"and try again.", "code": "rate_limited"}
|
||||
if r.status_code >= 400:
|
||||
return False, body if isinstance(body, dict) else {"detail": str(body)}
|
||||
return True, body
|
||||
except Exception as e:
|
||||
logger.error(f"C2 redeem_guild_link failed: {e}")
|
||||
return False, {"detail": "Could not reach DRB. Try again in a minute.", "code": "c2_unreachable"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trips
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Unlinked servers see only /drb link and /help (server-26#213).
|
||||
|
||||
Discord registers slash commands globally, so an unlinked server still lists
|
||||
the others in its command picker; this gate answers them with how to link
|
||||
instead of running them. Per-guild command sync would hide them outright but
|
||||
costs one sync per server per change — not worth it while C2 refuses every
|
||||
unlinked request anyway (this gate is UX, C2 is the enforcement).
|
||||
|
||||
Whether a server is "linked" is C2's answer: its own discord_guilds link, or
|
||||
DISCORD_UNLINKED_GUILD_ORG_ID when that is set — so with the default
|
||||
("founding") nothing is gated, exactly as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from app.internal.c2_client import c2
|
||||
from app.internal.logger import logger
|
||||
|
||||
ALWAYS_ALLOWED = frozenset({"drb link", "help"})
|
||||
LINKED_TTL = 60.0
|
||||
UNLINKED_TTL = 10.0 # short, so a fresh /drb link (or a web-side link) shows up fast
|
||||
|
||||
UNLINKED_TEXT = (
|
||||
"This server is not linked to a DRB organization yet.\n"
|
||||
"An owner of the DRB organization generates a code under **Settings → Organization → Discord servers**; "
|
||||
"then someone with **Manage Server** here runs `/drb link code:<code>`."
|
||||
)
|
||||
|
||||
_cache: dict[int, tuple[float, bool]] = {}
|
||||
|
||||
|
||||
async def is_linked(guild_id: int) -> Optional[bool]:
|
||||
"""True/False from C2, or None when C2 could not be reached."""
|
||||
hit = _cache.get(guild_id)
|
||||
if hit and time.monotonic() < hit[0]:
|
||||
return hit[1]
|
||||
data = await c2.guild_overview(guild_id)
|
||||
if data.get("error"):
|
||||
return None
|
||||
linked = bool(data.get("org_id"))
|
||||
_cache[guild_id] = (time.monotonic() + (LINKED_TTL if linked else UNLINKED_TTL), linked)
|
||||
return linked
|
||||
|
||||
|
||||
def forget(guild_id: int) -> None:
|
||||
_cache.pop(guild_id, None)
|
||||
|
||||
|
||||
class GatedTree(app_commands.CommandTree):
|
||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||||
if interaction.guild_id is None:
|
||||
return True
|
||||
if interaction.type not in (discord.InteractionType.application_command,
|
||||
discord.InteractionType.autocomplete):
|
||||
return True
|
||||
cmd = interaction.command
|
||||
if cmd is not None and cmd.qualified_name in ALWAYS_ALLOWED:
|
||||
return True
|
||||
# None = C2 unreachable: let the command run and report that itself.
|
||||
if await is_linked(interaction.guild_id) is not False:
|
||||
return True
|
||||
try:
|
||||
if interaction.type is discord.InteractionType.autocomplete:
|
||||
await interaction.response.autocomplete([])
|
||||
else:
|
||||
await interaction.response.send_message(UNLINKED_TEXT, ephemeral=True)
|
||||
except discord.HTTPException as e:
|
||||
logger.warning(f"unlinked-guild reply failed: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user