Merge pull request 'docs(teams): phase 7 as built — five amendments to §7.1' (#161) from feature/teams-phase7-slash-commands into edge
Reviewed-on: #161
This commit is contained in:
@@ -674,7 +674,7 @@ are authoritative, and they answer different questions:
|
||||
|
||||
| Artifact | Source of truth for | Generated by |
|
||||
|---|---|---|
|
||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** 166 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** Every core URL — the public app plus the internal listener — sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||
| `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
||||
|
||||
Both are **core's**. An installed module's routes are in neither: they are in that module's own
|
||||
|
||||
@@ -59,11 +59,9 @@ the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSl
|
||||
> `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on its own page for CORE to
|
||||
> fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
|
||||
|
||||
**The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven
|
||||
are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase
|
||||
that will implement it — chosen over leaving it absent so that a module written against the published
|
||||
version fails at registration with a sentence explaining itself, rather than at whatever moment
|
||||
someone first exercises the feature. Do not call it yet; do not treat its throw as a bug.
|
||||
**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still
|
||||
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A
|
||||
module may call any member of this version and get the behaviour documented below.
|
||||
|
||||
**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every
|
||||
existing one is either the module claiming a mount or core notifying it; the closest precedent is
|
||||
@@ -301,7 +299,7 @@ api.registerNotificationStreams(streams)
|
||||
api.registerAnnounceLeg({ leg, label, dispatch, classify })
|
||||
api.registerPostHook({ onSaved, onDeleted })
|
||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
|
||||
api.registerSlashCommands([...]) // 1.6.0, throws until phase 7
|
||||
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
|
||||
api.onBoot(async (ctx) => {})
|
||||
api.onShutdown(async () => {})
|
||||
```
|
||||
@@ -492,10 +490,74 @@ removals. It defaults to `true` when omitted, so the ordinary authoritative case
|
||||
omitted from a roster is indistinguishable, downstream, from that member having left — core would
|
||||
mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
|
||||
|
||||
**`registerSlashCommands(commands)`** — declared in API 1.6.0 and **not yet implemented**: calling it
|
||||
throws with an error naming the phase that will. Present rather than absent so a module written
|
||||
against the published version fails at registration with an explanation, instead of at the moment
|
||||
someone first types the command.
|
||||
**`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both
|
||||
belong to the module, live since phase 7 (TEAMS.md §7.1).
|
||||
|
||||
```js
|
||||
api.registerSlashCommands([{
|
||||
name: 'guild', // lowercase, 1-32, no dots
|
||||
description: 'Show a guild on this shard', // 1-100 characters
|
||||
options: [ // the restricted schema, below
|
||||
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
|
||||
],
|
||||
access: 'everyone', // 'everyone' | 'linked' | 'staff'
|
||||
async handler({ command, options, actor }) {
|
||||
return { title, text, fields, url, ephemeral, notice } // every field optional
|
||||
},
|
||||
}])
|
||||
```
|
||||
|
||||
**The handler runs in the WEBSITE process, never in the bot.** The bot container has no `modules`
|
||||
volume and cannot load a line of module code, so it pulls the definitions over an internal API and
|
||||
owns every platform-specific concern — deferral, the acknowledgement deadline, ephemerality,
|
||||
follow-ups, embeds. A module that wanted to call `interaction.deferReply()` would be a module holding
|
||||
a Discord handle, and this split is the reason a second platform could implement the same contract.
|
||||
|
||||
**`actor` is resolved by core before the handler is entered**, and is the whole of what a handler
|
||||
learns about the caller:
|
||||
|
||||
| field | |
|
||||
| --- | --- |
|
||||
| `platform` | `'discord'` today; the only platform-shaped thing a handler ever sees |
|
||||
| `platformUserId` | the caller's id on that platform |
|
||||
| `guildId` | the platform community the command was run in, or `null` |
|
||||
| `userId` | the site account, or `null` when the platform identity is not linked |
|
||||
| `role` | that account's role — a module with audience rungs needs more than a boolean |
|
||||
| `isLinked` | whether `userId` resolved |
|
||||
| `isStaff` | `admin` or `moderator`, the same two roles every other Team surface means |
|
||||
|
||||
A **banned or disabled** account resolves as unlinked, so a chat surface is never the one place a ban
|
||||
does not reach. The Discord provider is found by `auth_providers.kind`, not by its id — the id is an
|
||||
operator-chosen slug.
|
||||
|
||||
**`access` is enforced twice, and only the server half is the gate.** The bot sets a platform-side
|
||||
permission default from it where the platform can express one; core re-checks it in the dispatcher on
|
||||
every call. `'linked'` has no Discord equivalent at all — there is no "has a website account"
|
||||
predicate — so it is simply not advertised, which is exactly why the client half cannot be the
|
||||
boundary.
|
||||
|
||||
**The option schema is deliberately small: `string | integer | boolean | user`,** each with
|
||||
`required` and optional `choices` (`string` and `integer` only). No subcommand groups, autocomplete,
|
||||
attachments, modals or component interactions — those are the features whose semantics do not survive
|
||||
a second platform. A command needing them is a bot-side command, written in the bot.
|
||||
|
||||
**A definition the platform would reject fails at `register()`**, not at the next connection: the bot
|
||||
registers the whole set in one call, so one bad option type would cost every command, the bot's own
|
||||
included. Names are validated (lowercase, 1-32, no dots), as are description lengths, the option
|
||||
types, and the ordering rule that a required option may not follow an optional one.
|
||||
|
||||
**Commands are NOT namespaced under the module id**, unlike stream ids and announce legs — Discord's
|
||||
name grammar has no `.` in it. Collisions are first-come with the holder named, and a name that
|
||||
collides with one of the bot's own built-ins is dropped by the bot, which is the one collision core
|
||||
cannot see.
|
||||
|
||||
**A handler's failure is its own.** A throw, or a handler still running after core's timeout, becomes
|
||||
a refusal the platform renders; the handler never runs in the bot process, so it cannot cost anything
|
||||
but its own reply. `ok` is core's verdict and sits outside the envelope, so a handler cannot forge it.
|
||||
|
||||
**A disabled module's commands stop answering immediately.** Registration has no removal path — a
|
||||
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
|
||||
switches a module off does not leave a live handler behind it.
|
||||
|
||||
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
||||
|
||||
|
||||
@@ -100,11 +100,14 @@ module fills with anything live. Core does not grow an SSE stack for this.
|
||||
exactly two shared-secret HTTP channels:
|
||||
|
||||
- **app → bot**, `utils/botInternalClient.js` → `bot/src/internal/internal.routes.js`
|
||||
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`), 4s timeout,
|
||||
never throws, always returns `{ ok, status, data, error }`.
|
||||
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`, and since
|
||||
phase 7 `/internal/refresh-commands`), 4s timeout, never throws, always returns
|
||||
`{ ok, status, data, error }`.
|
||||
- **bot → app**, `SITE_INTERNAL_URL=http://app:3001/internal/bot-config` on the app's *unpublished*
|
||||
internal listener (`server/src/internalApp.js`), with a retry-with-backoff bootstrap so a bot
|
||||
restart self-heals.
|
||||
restart self-heals. Phase 7 added `bot/src/site/appInternalClient.js` for `/internal/commands` and
|
||||
`/internal/commands/dispatch` on that same listener — it derives the base from `SITE_INTERNAL_URL`'s
|
||||
origin rather than taking a second variable naming the same host.
|
||||
|
||||
Slash commands are registered from a static array (`bot/src/discord/commands/index.js`) and pushed
|
||||
with `REST.put(Routes.applicationGuildCommands(...))` on ready (`discordManager.js:25`) — a **whole-set
|
||||
@@ -1669,6 +1672,42 @@ is already being built there.
|
||||
|
||||
### 7.1 Slash-command registration
|
||||
|
||||
> **Amended 2026-08-18 (phase 7), as built.** Five changes, four of them forced by what the tree
|
||||
> already looked like.
|
||||
>
|
||||
> **The example command is `/guild`, registered by module-uo, and core registers none.** §7.1 wrote
|
||||
> `/team` as a core command against core's own Team rows. Phase 3 settled that **Teams is a contract
|
||||
> primitive with no core surface** — core does not own the word for a Team, which is why four core
|
||||
> Team pages were deleted — and a core `/team` publishes that same invented noun into a channel. The
|
||||
> module owns the vocabulary, so the module owns the command. Core ships the dispatcher, the actor
|
||||
> resolver and the transport, and zero commands.
|
||||
>
|
||||
> **The deep link comes from `pageUrlTemplate`, because `/teams/:slug` does not exist.** The snippet
|
||||
> below still says `${siteBaseUrl}/teams/${slug}`; there is no such page. A handler builds its own
|
||||
> link — module-uo's is `/uo/guilds/{externalId}` — which is the same hole phase 6 found in the mail
|
||||
> path and closed with the ninth contract member.
|
||||
>
|
||||
> **The re-register nudge is its own endpoint, `POST /internal/refresh-commands` on the bot**, not a
|
||||
> ride on `/internal/config`. That body carries the DECRYPTED bot token: telling the bot that a
|
||||
> module changed should not require reading a secret out of the database to say it.
|
||||
>
|
||||
> **`actor` carries `role` as well as `isStaff`.** The two answer different questions and a boolean
|
||||
> loses one — `isStaff` is core's gate for `access: 'staff'`, `role` is what a module with its own
|
||||
> audience rungs needs to place the caller on them. It is the pair `projectRoster`'s viewer already
|
||||
> carried (§3.3), not a new class of disclosure.
|
||||
>
|
||||
> **Deregistration needed a second half this section did not consider.** "A module that is gone is
|
||||
> simply absent from the next pull" holds across the restart an uninstall asks for. It does not hold
|
||||
> for the runtime toggle: the registries have no removal path, so a module an operator disables would
|
||||
> keep a live handler behind a command Discord still advertises. Liveness is therefore asked at both
|
||||
> the pull and the dispatch, and a disabled owner's command answers `unknown`.
|
||||
>
|
||||
> The envelope also gained **`notice`** — a private aside delivered beside a public answer, which is
|
||||
> how §9 answer 5's "public projection plus an ephemeral prompt to link" is actually expressible: one
|
||||
> reply cannot be both public and ephemeral, and that it becomes a follow-up is the platform's
|
||||
> decision, not the handler's.
|
||||
|
||||
|
||||
**Ownership, decided:** the registrant owns the **definition and the handler**; the handler runs **in
|
||||
the website process** and returns a **response envelope**; the **bot owns every Discord-specific
|
||||
concern** — deferral, the 3-second ack, ephemerality, follow-ups, interaction tokens, embeds. This is
|
||||
@@ -2485,13 +2524,37 @@ them as toggles automatically, but nothing here builds a Team screen or a deep-l
|
||||
app, so a Team tickle on mobile opens the app and no more. That is a stated limitation, not an
|
||||
oversight.
|
||||
|
||||
### Phase 7 — Discord: slash commands (`website` + `bot` + `docs`)
|
||||
### Phase 7 — Discord: slash commands (`website` + `module-uo` + `docs`) — **DONE 2026-08-18**
|
||||
|
||||
`api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's
|
||||
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and `/team` as the first
|
||||
command through it.
|
||||
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command
|
||||
through it.
|
||||
|
||||
**Ships:** a working `/team`, and the seam a module needs for its own commands.
|
||||
**Ships:** a working `/guild`, and the seam a module needs for its own commands.
|
||||
|
||||
**THREE repos, not the plan's `website` + `bot` + `docs` — `bot` is not a repo.** It is a workspace
|
||||
inside `website`, so the bot half lands in the same PR as the server half; `module-uo` joins instead,
|
||||
because the command that proves the seam belongs to the module and not to core (see the amendment at
|
||||
the head of [§7.1](#71-slash-command-registration)).
|
||||
|
||||
**Walked on the live rig before the PRs opened** — real ServUO + real sidecar (protocol 4) + the app
|
||||
with module-uo installed, with the bot's own pull/execute path driven against it and a fake standing
|
||||
in for Discord. It proved the audience rung holding over the chat surface (guilds gated to `staff`:
|
||||
anonymous and linked-player refused, linked admin served, same command), the Discord provider
|
||||
resolving by `kind` on a deployment whose provider slug is `my-discord`, a banned account resolving as
|
||||
unlinked, the disable nudge firing with its reason and degrading to a log line with no bot running,
|
||||
and the pull emptying plus dispatch answering `unknown` for a module switched off at runtime.
|
||||
|
||||
**It found two defects, both folded in.** A refusal was posted PUBLICLY — ephemerality is fixed at the
|
||||
deferral, before the handler has said anything, so the envelope's flag was read and ignored, and "not
|
||||
shown to your account" announced a member's access level to the channel. And the refusal offered
|
||||
linking on a shard gated to `staff`, where linking reaches `player` and stops.
|
||||
|
||||
**The bot got its first test harness.** It had no `test` script and no tests at all — CI ran
|
||||
`npm ci --prefix bot` and nothing else — which was defensible while the bot only wired up its own
|
||||
static commands. It is not defensible now that it merges a pulled set into a single all-or-nothing
|
||||
registration and runs the interaction path, and phases 8 and 9 add more. `bot/test/` and a
|
||||
`bot-tests` job replace `bot-install`.
|
||||
|
||||
### Phase 8 — Discord: notifications bridge (`website` + `bot`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user