Discord radio channels: wire leases + desired-state streams (server-26#203, #204)
c2-core
- discord_streams/discord_leases wired in: POST /discord/streams,
POST /discord/guilds/{id}/stop, GET /discord/guilds/{id}/overview,
GET /discord/streams/{id}, PUT /discord/guilds/{id}/link (admin),
GET /discord/node/{node}/tokens/{id} (node key, lease holder only).
- Retained nodes/{id}/discord/desired (token ids, never token strings),
nodes/+/discord/actual subscribed and handled; per-node publish lock keeps
the retained list the newest; failed publishes retried by the reconciler.
- Tokens freed only after the node acks logout (or offline past
discord_lease_offline_release_seconds) - sweeper and C2 startup no longer
release leases.
- /nodes/{id}/command discord_join/leave map onto streams; CommandPayload
keeps preferred_token_id. /tokens: admin-only list, validate on add against
Discord, refresh, flush via transactional force release.
- Guild -> org via discord_guilds, unlinked guilds fall back to
DISCORD_UNLINKED_GUILD_ORG_ID (default "founding").
- tests/fake_firestore.py: optimistic-transaction fake; 27 lease/race tests.
discord bot
- /join, /leave, /status, autocomplete scoped to the calling guild; /leave
never touches another server; Manage Channels default permission;
/join edits its reply with the node's real result; /help copy fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0a724a064f
commit
a31a8df17c
@@ -13,60 +13,60 @@ class C2Client:
|
||||
return {"Authorization": f"Bearer {settings.c2_service_key}"}
|
||||
return {}
|
||||
|
||||
async def get_nodes(self) -> list:
|
||||
# ------------------------------------------------------------------
|
||||
# Discord radio channels — every call is scoped to one guild
|
||||
# (server-26#203). C2 resolves the guild's org; nothing here sees another
|
||||
# org's nodes, systems or bots.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def guild_overview(self, guild_id: int | str) -> dict:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{self.base}/nodes", headers=self._headers())
|
||||
r = await client.get(f"{self.base}/discord/guilds/{guild_id}/overview", headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 get_nodes failed: {e}")
|
||||
return []
|
||||
logger.error(f"C2 guild_overview failed: {e}")
|
||||
return {"guild_id": str(guild_id), "org_id": None, "systems": [], "nodes": [],
|
||||
"streams": [], "bots": [], "error": True}
|
||||
|
||||
async def get_systems(self) -> list:
|
||||
async def start_stream(self, payload: dict) -> tuple[bool, dict]:
|
||||
"""Returns (ok, body). On a refusal body carries detail/code/invite."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(f"{self.base}/discord/streams", json=payload, headers=self._headers())
|
||||
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
||||
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 start_stream failed: {e}")
|
||||
return False, {"detail": "Could not reach DRB. Try again in a minute.", "code": "c2_unreachable"}
|
||||
|
||||
async def get_stream(self, stream_id: str) -> Optional[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{self.base}/systems", headers=self._headers())
|
||||
r = await client.get(f"{self.base}/discord/streams/{stream_id}", headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 get_systems failed: {e}")
|
||||
return []
|
||||
logger.error(f"C2 get_stream failed: {e}")
|
||||
return None
|
||||
|
||||
async def get_tokens(self) -> list:
|
||||
async def stop_guild(self, guild_id: int | str, channel_id: Optional[str] = None,
|
||||
reason: str = "leave") -> Optional[list]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{self.base}/tokens", headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 get_tokens failed: {e}")
|
||||
return []
|
||||
|
||||
async def send_command(self, node_id: str, payload: dict) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/nodes/{node_id}/command",
|
||||
json=payload,
|
||||
f"{self.base}/discord/guilds/{guild_id}/stop",
|
||||
json={"channel_id": channel_id, "reason": reason},
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
return r.json().get("stopped", [])
|
||||
except Exception as e:
|
||||
logger.error(f"C2 send_command failed: {e}")
|
||||
return False
|
||||
|
||||
async def find_node_for_system(self, system_id: str) -> Optional[dict]:
|
||||
"""Return the first online node assigned to the given system."""
|
||||
nodes = await self.get_nodes()
|
||||
for node in nodes:
|
||||
if (
|
||||
node.get("assigned_system_id") == system_id
|
||||
and node.get("status") in ("online", "recording")
|
||||
):
|
||||
return node
|
||||
return None
|
||||
logger.error(f"C2 stop_guild failed: {e}")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trips
|
||||
|
||||
Reference in New Issue
Block a user