Discord radio channels: transactional token leases + desired-state streams (#203, #204) #216

Merged
logan merged 4 commits from feat/discord-system into main 2026-10-04 18:34:10 -04:00
Owner

Fixes server-26#203 and server-26#204. Companion edge PR: logan/node-26 feat/discord-system (node-26#25). Merge both together — this server no longer sends raw-token discord_join commands, and the new edge ignores them, so Discord radio is down between the two merges until the node takes its OTA update.

What changed

c2-core

  • internal/discord_leases.py + internal/discord_streams.py (the WIP commit) wired in. Each token lease is a Firestore transaction: one node per token, one stream per (token, guild), a re-join reuses the lease.
  • New routers/discord.py: POST /discord/streams, GET /discord/streams/{id}, POST /discord/guilds/{id}/stop, GET /discord/guilds/{id}/overview (service key or admin); PUT /discord/guilds/{id}/link (admin); GET /discord/node/{node}/tokens/{id} (the node's own key, and only the lease holder gets the token).
  • MQTT: retained nodes/{id}/discord/desired carries token ids, never token strings. C2 subscribes to nodes/+/discord/actual. A per-node publish lock keeps the retained list the newest one, and the reconciler retries publishes that failed (#204 item 3).
  • Release is ordered: a token is freed only after the node acks a desired seq that dropped it and reports it logged out, or after the node has been offline for DISCORD_LEASE_OFFLINE_RELEASE_SECONDS (600). The sweeper and C2 startup no longer release leases.
  • /nodes/{id}/command maps discord_join/discord_leave onto streams (node pinned). CommandPayload.preferred_token_id is now kept (#204 item 1).
  • /tokens: the list is admin-only (#204 item 6). Tokens are validated on add against Discord's users/@me (a 401 is refused) and get guild_ids/application_id/invite_url. New POST /tokens/{id}/refresh. Flush uses a transactional force-release and republishes desired state.
  • Guild → org comes from discord_guilds. An unlinked guild falls back to DISCORD_UNLINKED_GUILD_ORG_ID (default founding, which keeps today's deployment working). See #213.

discord bot

  • /join, /leave, /status and autocomplete are scoped to the calling guild's org. /leave stops only this server's streams (optional channel).
  • /join and /leave require Manage Channels by default and can be reassigned under Integrations.
  • /join edits its reply with the node's real result (connected, or an error such as "not in this server" with invite links). Added a persistent option. Fixed the /help copy (no Icecast).

Tests

  • c2-core: 552 passed, 3 warnings (baseline on main: 525 passed). That includes 27 new tests in tests/test_discord_leases.py. They run real concurrent transactions against tests/fake_firestore.py, an optimistic-commit fake that re-checks every read. A mutation check confirmed the race tests fail when conflict detection is removed.
  • Discord bot: import and pyflakes only. It has no test suite, so it is unverified beyond that.
  • Frontend: no changes.

Not done

  • Real Firestore transaction behaviour (fs.transactional, collection.stream(transaction=)) is untested against the live SDK. Only the fake has run it.
  • #213: no self-serve guild link (/drb link) and no admin UI for streams/bots. The tokens page doesn't show the new fields yet. The non-admin systems-page token picker now gets 403.
  • #214: ad-hoc streams don't idle-dismiss yet.
  • Per-channel talkgroup filter (tg_filter) is still a phase-2 seam, always None.

🤖 Generated with Claude Code

Fixes server-26#203 and server-26#204. Companion edge PR: logan/node-26 `feat/discord-system` (node-26#25). **Merge both together** — this server no longer sends raw-token `discord_join` commands, and the new edge ignores them, so Discord radio is down between the two merges until the node takes its OTA update. ## What changed **c2-core** - `internal/discord_leases.py` + `internal/discord_streams.py` (the WIP commit) wired in. Each token lease is a Firestore transaction: one node per token, one stream per (token, guild), a re-join reuses the lease. - New `routers/discord.py`: `POST /discord/streams`, `GET /discord/streams/{id}`, `POST /discord/guilds/{id}/stop`, `GET /discord/guilds/{id}/overview` (service key or admin); `PUT /discord/guilds/{id}/link` (admin); `GET /discord/node/{node}/tokens/{id}` (the node's own key, and only the lease holder gets the token). - MQTT: retained `nodes/{id}/discord/desired` carries token **ids**, never token strings. C2 subscribes to `nodes/+/discord/actual`. A per-node publish lock keeps the retained list the newest one, and the reconciler retries publishes that failed (#204 item 3). - Release is ordered: a token is freed only after the node acks a desired `seq` that dropped it **and** reports it logged out, or after the node has been offline for `DISCORD_LEASE_OFFLINE_RELEASE_SECONDS` (600). The sweeper and C2 startup no longer release leases. - `/nodes/{id}/command` maps `discord_join`/`discord_leave` onto streams (node pinned). `CommandPayload.preferred_token_id` is now kept (#204 item 1). - `/tokens`: the list is admin-only (#204 item 6). Tokens are validated on add against Discord's `users/@me` (a 401 is refused) and get `guild_ids`/`application_id`/`invite_url`. New `POST /tokens/{id}/refresh`. Flush uses a transactional force-release and republishes desired state. - Guild → org comes from `discord_guilds`. An unlinked guild falls back to `DISCORD_UNLINKED_GUILD_ORG_ID` (default `founding`, which keeps today's deployment working). See #213. **discord bot** - `/join`, `/leave`, `/status` and autocomplete are scoped to the calling guild's org. `/leave` stops only this server's streams (optional `channel`). - `/join` and `/leave` require Manage Channels by default and can be reassigned under Integrations. - `/join` edits its reply with the node's real result (connected, or an error such as "not in this server" with invite links). Added a `persistent` option. Fixed the `/help` copy (no Icecast). ## Tests - c2-core: `552 passed, 3 warnings` (baseline on main: 525 passed). That includes 27 new tests in `tests/test_discord_leases.py`. They run real concurrent transactions against `tests/fake_firestore.py`, an optimistic-commit fake that re-checks every read. A mutation check confirmed the race tests fail when conflict detection is removed. - Discord bot: import and pyflakes only. It has no test suite, so it is **unverified** beyond that. - Frontend: no changes. ## Not done - Real Firestore transaction behaviour (`fs.transactional`, `collection.stream(transaction=)`) is untested against the live SDK. Only the fake has run it. - #213: no self-serve guild link (`/drb link`) and no admin UI for streams/bots. The tokens page doesn't show the new fields yet. The non-admin systems-page token picker now gets 403. - #214: ad-hoc streams don't idle-dismiss yet. - Per-channel talkgroup filter (`tg_filter`) is still a phase-2 seam, always None. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 2 commits 2026-10-04 18:10:33 -04:00
Paused by owner instruction; not wired into routers/mqtt yet, untested.

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>
logan added 1 commit 2026-10-04 18:16:46 -04:00
- 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>
Author
Owner

Adversarial review pushed 03ba7ea on this branch:

  • Token leak: 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 the node came back. It now sweeps leased tokens on nodes that are offline or deleted past the lease grace. For a deleted node, the grace is measured from its last discord/actual report.
  • Release race: publish_desired stamped release_seq after building the list. A stop landing between the two could be freed by an ack for a list that still held the stream. It now stamps first.

Added 3 regression tests, and each one fails on a31a8df. c2-core suite: 555 passed.

Adversarial review pushed `03ba7ea` on this branch: - **Token leak:** `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 the node came back. It now sweeps leased tokens on nodes that are offline or deleted past the lease grace. For a deleted node, the grace is measured from its last `discord/actual` report. - **Release race:** `publish_desired` stamped `release_seq` after building the list. A stop landing between the two could be freed by an ack for a list that still held the stream. It now stamps first. Added 3 regression tests, and each one fails on `a31a8df`. c2-core suite: 555 passed.
logan added 1 commit 2026-10-04 18:32:46 -04:00
logan merged commit 99407c1df0 into main 2026-10-04 18:34:10 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#216