- handle_actual logs every node-reported state TRANSITION (not heartbeats)
with stream/node/guild/channel/token ids and the node's error text: info
for connected/connecting/stopped, warning for error, and a dedicated
"radio bot X failed to log in on node N: <reason>" line for login_failed.
The node's reason code is now stored in actual.code and shown next to the
error on Settings > Discord and the node page.
- Remove a radio channel permanently: streams.delete_stream stops it through
desired state first (node republished without it, token -> releasing per
#216, freed on the node's ack), then deletes the doc in a transaction that
refuses if the channel was restarted in between. DELETE
/discord/streams/{id} (platform admin) and DELETE
/discord/nodes/{node}/streams/{id} (admin or org owner, same org + node
only; operators can still stop, not remove). Audited.
- Remove a server: DELETE /discord/guilds/{id} (admin) deletes every radio
channel in it the same way, then tombstones discord_guilds (org_id None +
unlinked_at, #218 semantics, plus removed_at) so it never falls back to
DISCORD_UNLINKED_GUILD_ORG_ID; the admin page hides it, and /drb link or the
admin link brings it back. Optional leave_radio_bots makes pool bots and the
server's org's own BYO bots leave via Discord REST; the DRB server bot stays
until kicked, and the UI says so. Audited.
- UI: Remove (with confirm) on Radio channels, Servers and the node page list.
Refs #213, #221
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- reconcile_once only walked desired=on streams, so a token left "releasing"
by /leave while its node was offline (or a deleted node) never returned to
the pool until that node came back (server-26#204 leak shape). Sweep leased
tokens whose node is offline/deleted past the lease grace.
- publish_desired stamped release_seq AFTER building the list, so a stop
landing between the two got a seq whose list still held the stream; an ack
for it could free a token the node was still told to use. Stamp first.
Three regression tests; each fails on the previous commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>