Skip to content

feat(api): let a guild be set up from the console, by asking the bot to do it - #149

Merged
TheMeinerLP merged 1 commit into
mainfrom
feat/api-guild-onboarding
Aug 23, 2026
Merged

feat(api): let a guild be set up from the console, by asking the bot to do it#149
TheMeinerLP merged 1 commit into
mainfrom
feat/api-guild-onboarding

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Setting a guild up is the one thing the console could not do, and the reason is not an oversight: every step of it that matters needs a Discord token, and api must never hold one. Creating the consent role, denying Speak to @everyone and allowing it for that role, registering the command tree — all gateway operations. charts/sturnus/templates/_helpers.tpl enforces the split and the console design's §2.1 says why: a process that can decrypt every recording ever made is not one to also give the ability to act as the bot.

So this does not give api a token, and it does not have api write guild_config either. It writes an intent, which is the mirror arrangement run backwards. The bot mirrors Discord state into the database for api to read; api now writes down what should be true, and the bot's existing ten-second reconcile tick makes it true and writes back what happened.

Migration 0013 already created guild_setup_intent and SetupIntentStore. Nothing read or wrote either. This is the code on both ends of them.


One planner, two callers

The bot applies an intent through sturnus.application.setup_plan.plan_setup — the same pure function /setup calls. That is not tidiness. plan_setup's own comment calls the Speak overwrites "the primary layer of the consent protection (Spec 3.1)": deny Speak to @everyone, allow it for the consent role, on every allowed channel. A second implementation that got one of those backwards would let somebody be recorded without having consented.

The planner needed two honest generalisations to serve both callers, and neither changes what /setup does:

  • channel_id: int became added_channel_ids: tuple[int, ...]. The slash command names one room, because Discord renders one channel picker per parameter; the console names the whole list somebody ticked. /setup passes (channel.id,) and nothing about its behaviour moves.
  • policy_url and policy_version became str | None, where None means "this caller was not told". /setup is always told — they are required command parameters. A console intent carries neither, deliberately: the policy is set on the settings page, and a second place to write policy_version is a second way to invalidate every consent in a guild. None writes nothing and reports the key in missing if it is unset, which is the honest answer for a guild that cannot record yet.

missing is now derived from what the caller was actually told rather than from a fixed "always supplied" set, which is what makes that possible.

The applying half was shared too, into sturnus.infrastructure.discord.setup_apply: reading a channel's Speak overwrites and writing them back is as much the consent protection as planning them is, and SetupCog no longer owns either. Its error wording is unchanged — it was already written for a person to read, and it now reads the same in an ephemeral Discord reply and in guild_setup_intent.error rendered in the console.

The contradiction rule: the newest ask wins, outright

Two administrators submitting different intents thirty seconds apart, or one impatient person pressing twice, must not leave the bot configuring a guild twice in a row. select_intent applies the newest unapplied intent and settles every older one as superseded, unapplied.

An intent states what should be true. Two statements of what should be true do not compose — applying both in request order would finish on the older list, which is precisely the correction being overwritten by the mistake it corrected.

Refusing a request while one is pending was the alternative, and it is worse in both directions. An administrator who mistyped a channel would have to wait out a tick before they could correct it, and their correction would then be a second full setup applied on top of the first. And an intent that never settles — because the bot has not been invited yet, which is the ordinary state during onboarding — would lock that guild out of being set up at all.

Where the rule lives matters. api refuses nothing and writes every request down: an administrator who asked twice asked twice, and collapsing the two would lose who asked for which and when. The decision is taken where the guild is configured, by the process that configures it — which also means no lock api would have to hold across two replicas.

The retry bound is one attempt

The tick runs six times a minute forever. An intent left pending after being applied would re-create the role and re-write the overwrites for the life of the guild; one left pending after failing would retry a permission error against Discord's rate limiter just as often.

An attempt settles the intent whichever way it went. error carries something a person can act on — which channel, which permission, what to do about it — and an administrator who has fixed the permission asks again, which is a new row saying who asked and when. There is no back-off to tune because there is no second attempt to back off from. This is the table's own documented design; this branch is the first code to hold to it.

A failure means not everything the bot was asked to do happened, not nothing happened. The configuration writes land regardless, exactly as /setup's do — refusing to store what the bot did determine because a permission edit failed would leave the guild worse off than before the request.

Two failure modes are worth naming:

  • A channel the request named that the bot cannot see is refused rather than added. The console picks from the mirror, so this means the mirror is behind Discord — and adding the room anyway would put it on the allowed list with nobody's Speak overwrites written, which is a hole in the consent protection that looks exactly like a configured guild.
  • A channel already stored and now unseeable is left alone. It is not this request's doing, and failing every future setup over a room somebody deleted last week would leave the guild unable to change anything.

"The bot is not there yet" is a first-class answer

A guild the bot has not joined has empty mirrors — no channels, no roles. A channel picker rendering empty for that reason reads exactly like a server that genuinely has no voice channels, and sends somebody looking for a bug that is not there.

GET /api/guilds/{id}/setup carries bot.has_arrived, which is false exactly while nothing about the guild has ever been mirrored, and bot.seen_at, present-and-null rather than absent. An empty channel list means "this server has no voice channels" only when has_arrived is true.

The bot side matches: a guild this process cannot see is skipped and its intents stay pending, never failed. Failing them would be terminal, and a guild whose invite is still being clicked would have to be asked about a second time for nothing.

The invite link

The one genuinely web-doable step: a bot-scope authorize URL is public and buildable from the application's client id alone. GET /api/invite serves it with the scopes and bitmask docs/first-deployment.md §2 tells an operator to tick by hand — 269487104, pinned as a literal against that document.

Manage Roles is the permission that fails latest, and its reasoning is recorded beside the constant: it covers both halves of what an intent asks for, and a bot invited without it joins happily, mirrors happily, and fails the first request it is handed. Discord's role position is not in the bitmask and cannot be — the bot's own role must sit above the consent role or Discord refuses the edit, which is why an intent can still fail on a guild that granted every permission on the list.

STURNUS_DISCORD_CLIENT_ID is plain configuration in api.env, never the Secret: an application id is public by design and grants this process nothing. It is optional — unset serves url: null rather than failing the deployment — but a value that is not a snowflake fails at startup, because the alternative is handing somebody a Discord page that cannot say what it is asking them to authorise.


Routes and JSON shapes

No console change in this branch. Every shape the interface needs is here.

GET /api/invite

{
  "client_id": "1289374650912837465",
  "url": "https://discord.com/oauth2/authorize?client_id=1289374650912837465&scope=bot+applications.commands&permissions=269487104",
  "permissions": "269487104",
  "scopes": ["bot", "applications.commands"]
}

client_id and url are both null when the deployment has no application id. permissions and scopes are sent either way — they are what the page tells somebody to tick if they build the link by hand instead.

GET /api/guilds/{guild_id}/setup → 200 · POST /api/guilds/{guild_id}/setup → 202

The same body from both, because there is nothing to wait on: under the rule that the newest ask wins, "what did I just ask for" and "what will this guild be configured from" are the same question — and if somebody else asked in between, the honest answer to both is theirs. The console polls until request.status stops being pending.

{
  "guild_id": "4711",
  "bot": { "has_arrived": true, "seen_at": "2026-08-23T12:00:00+00:00" },
  "request": {
    "id": "7",
    "status": "pending",
    "requested_by": "100",
    "requested_at": "2026-08-23T12:00:00+00:00",
    "channel_ids": ["386950399101370374"],
    "consent_role_name": "Recorded",
    "settled_at": null,
    "error": null
  }
}

request is null when nobody has ever asked. status is pending, applied, failed or superseded — and is not narrowed to those: outcome is text rather than a database enum precisely so a value this build has never seen is a row a reader can ignore, and an endpoint that refused to render one would give that property back. settled_at is null while pending; error is free text, non-null only on failed.

Every id is a string. A snowflake exceeds JavaScript's safe integer range, where a JSON number silently loses its last digits and produces an id that looks right and names nothing.

POST body

{ "channel_ids": ["386950399101370374", "386950399101370375"], "consent_role_name": "Recorded" }

consent_role_name is optional; absent or null keeps whatever role the guild already has, because omitting it must never be the destructive path (Spec 10.1). A name that matches an existing role reuses it rather than creating a second role with the same name; a name that matches nothing creates it.

channel_ids are strings, refused rather than coerced if they are numbers — a client that sent numbers has already lost them. Every rule about what the list may contain is settings.parse_channel_ids', reached by round-tripping the rendered value rather than restated here: a duplicate, an empty list and a non-integer are all refused there, with the reasons argued there.

Refusals

Status Body When
401 (session middleware) Not signed in — all three routes, the invite link included
404 {"error": "no such guild"} Guild does not exist, is not administered by the caller, or the path segment is not a number
400 {"error": "malformed request body"} Not JSON, or not an object
400 {"error": "channel_ids must be a list of snowflake strings"} Missing, not a list, or an entry that is not a string
400 {"error": "the channel list is not valid"} Empty, duplicated, or not a snowflake
400 {"error": "consent_role_name must be a string"} Present and not a string
400 {"error": "the consent role name is not valid"} Blank after trimming, or over Discord's 100 characters

404, never 403. Writing a setup request is an act on somebody else's server, and a 403 would confirm to somebody just established as having no business with that guild that it exists. Every reason is a fixed string; nothing a caller typed is reflected back.

All three responses carry Cache-Control: private, no-store.

Observability

console.setup_requested (INFO, in api) is the only record that a person asked, as opposed to that the bot acted — the two are separated by a tick, and by however long a guild takes to invite the bot. setup.intent_applied carries outcome and is WARNING when it is failed, because the person who asked is watching a page for an answer and there is no retry to wait for. setup.intent_superseded is the visible half of the contradiction rule. No new registered log field: guild, who, and a bounded literal, all of which the registry already carries.

Checks

2233 passed (baseline on main: 2135), mypy, ruff check and ruff format all clean. helm lint and helm template render.

The new tests are split by where each decision lives: tests/domain/test_onboarding.py for the contradiction rule and the invite link with no guild in sight, tests/application/test_setup_plan.py for the planner's second caller, tests/infrastructure/discord/test_setup_apply.py for what actually happens to a guild, tests/console/test_setup_routes.py for the three response shapes, and tests/console/test_setup_directory.py for the adapter against the real database. One test asserts, by parsing the tick's own source, that intents are applied before the reconcile that reads what they wrote — the order is the only thing that decides whether a guild set up from the console can record on this tick or the next one, so it is asserted rather than left to a comment.

@TheMeinerLP
TheMeinerLP force-pushed the feat/api-guild-onboarding branch 2 times, most recently from 3b2254e to 5ab95aa Compare August 23, 2026 14:27
…to do it

Every step of setting a guild up that matters needs a Discord token, and
`api` must never hold one. So the console writes an intent -- what should
be true -- and the bot's existing ten-second tick makes it true through
the same `plan_setup` the slash command uses, then writes back what
happened.

- `POST`/`GET /api/guilds/{id}/setup`, administrator of that guild only.
- `GET /api/invite`, built from the application id alone.
- The newest unapplied intent wins; older ones settle as `superseded`.
- An attempt settles an intent either way: one try, no retry loop.
- `bot.has_arrived` separates "no voice channels" from "not there yet".
@TheMeinerLP
TheMeinerLP force-pushed the feat/api-guild-onboarding branch from 5ab95aa to 2e83f18 Compare August 23, 2026 15:29
@TheMeinerLP
TheMeinerLP merged commit 67b02e9 into main Aug 23, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant