feat(api): let a guild be set up from the console, by asking the bot to do it - #149
Merged
Conversation
TheMeinerLP
force-pushed
the
feat/api-guild-onboarding
branch
2 times, most recently
from
August 23, 2026 14:27
3b2254e to
5ab95aa
Compare
…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
force-pushed
the
feat/api-guild-onboarding
branch
from
August 23, 2026 15:29
5ab95aa to
2e83f18
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
apimust never hold one. Creating the consent role, denyingSpeakto@everyoneand allowing it for that role, registering the command tree — all gateway operations.charts/sturnus/templates/_helpers.tplenforces 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
apia token, and it does not haveapiwriteguild_configeither. It writes an intent, which is the mirror arrangement run backwards. The bot mirrors Discord state into the database forapito read;apinow 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_intentandSetupIntentStore. 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/setupcalls. That is not tidiness.plan_setup's own comment calls the Speak overwrites "the primary layer of the consent protection (Spec 3.1)": denySpeakto@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
/setupdoes:channel_id: intbecameadded_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./setuppasses(channel.id,)and nothing about its behaviour moves.policy_urlandpolicy_versionbecamestr | None, whereNonemeans "this caller was not told"./setupis 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 writepolicy_versionis a second way to invalidate every consent in a guild.Nonewrites nothing and reports the key inmissingif it is unset, which is the honest answer for a guild that cannot record yet.missingis 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, andSetupCogno 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 inguild_setup_intent.errorrendered 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_intentapplies the newest unapplied intent and settles every older one assuperseded, 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.
apirefuses 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 lockapiwould 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.
errorcarries 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:
"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}/setupcarriesbot.has_arrived, which isfalseexactly while nothing about the guild has ever been mirrored, andbot.seen_at, present-and-null rather than absent. An empty channel list means "this server has no voice channels" only whenhas_arrivedis 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/inviteserves it with the scopes and bitmaskdocs/first-deployment.md§2 tells an operator to tick by hand —269487104, pinned as a literal against that document.Manage Rolesis 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_IDis plain configuration inapi.env, never theSecret: an application id is public by design and grants this process nothing. It is optional — unset servesurl: nullrather 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_idandurlare bothnullwhen the deployment has no application id.permissionsandscopesare 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→ 202The 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.statusstops beingpending.{ "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 } }requestisnullwhen nobody has ever asked.statusispending,applied,failedorsuperseded— and is not narrowed to those:outcomeis 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_atis null while pending;erroris free text, non-null only onfailed.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.
POSTbody{ "channel_ids": ["386950399101370374", "386950399101370375"], "consent_role_name": "Recorded" }consent_role_nameis optional; absent ornullkeeps 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_idsare 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 issettings.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
{"error": "no such guild"}{"error": "malformed request body"}{"error": "channel_ids must be a list of snowflake strings"}{"error": "the channel list is not valid"}{"error": "consent_role_name must be a string"}{"error": "the consent role name is not valid"}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, inapi) 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_appliedcarriesoutcomeand is WARNING when it isfailed, because the person who asked is watching a page for an answer and there is no retry to wait for.setup.intent_supersededis 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 onmain: 2135), mypy, ruff check and ruff format all clean.helm lintandhelm templaterender.The new tests are split by where each decision lives:
tests/domain/test_onboarding.pyfor the contradiction rule and the invite link with no guild in sight,tests/application/test_setup_plan.pyfor the planner's second caller,tests/infrastructure/discord/test_setup_apply.pyfor what actually happens to a guild,tests/console/test_setup_routes.pyfor the three response shapes, andtests/console/test_setup_directory.pyfor 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.