feat(rust): chat titles, BetterChat group styles, the voice and popups (phase 17)

PLAN.md §33, D134-D143. Protocol 12.

- Chat titles (D135-D137): per-server rules (stat, top N, text, colour)
  that rank the current wipe, and a mode (first | all | up to N). Worked
  out once in model/titles and read three ways: pushed whole to the game by
  a new titleSync loop (on change, restart or wipe), and on every
  leaderboard row as `titles`. Admin: PUT /servers/:id/titles.
- Group styles (D138, D139): a site group may carry all twelve BetterChat
  fields (rust_perm_group_chat). They ride perm.sync with `expect` from the
  pushed ledger, which gains a value column; a field changed in game is a
  `chat-field` drift row with the game's value, adopted into the style or
  put back. A withdrawn style is one `chat-group` retirement, never for
  `default`, cleared from the ledger only once BetterChat removed it.
- The voice (D140): one fleet setting naming a styled group; news and
  rust.announce chat lines carry its format and the plugin says them with
  no sender. Admin: GET/PUT /voice.
- Popups (D141, D142): rust.announce gains `delivery` (still version 1,
  from rust.options.delivery); each server gains news_delivery beside the
  news switch; `popup-unavailable` is not retried.
- GET /servers/:id/integrations reads, live, which optional mods a server
  has loaded. README lists BetterChat and PopupNotifications as optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 17:48:22 -05:00
parent fb5a581a94
commit 1b70cef5be
43 changed files with 3371 additions and 72 deletions

View File

@@ -164,7 +164,7 @@
"Admin · Rust"
],
"summary": "The whole permission model",
"description": "Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.",
"description": "Groups with their permissions, members and BetterChat style (`chat`, or null), direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server. A drift row of kind `chat-field` is a style field somebody changed in game: `subject` is the group, `object` the field and `detail` what the game holds. `chatFields` lists the twelve BetterChat fields with their types and defaults, for the style editor.",
"responses": {
"200": {
"description": "The authored model and what each game reported",
@@ -212,7 +212,7 @@
"Admin · Rust"
],
"summary": "Adopt a hand edit",
"description": "Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.",
"description": "Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link. For a `chat-field` row it copies the game’s value into the group’s style — which every server in the group’s scope is then pushed — and answers 409 when the group has no style or the value is not one the site accepts.",
"parameters": [
{
"name": "id",
@@ -248,7 +248,7 @@
"Admin · Rust"
],
"summary": "Revoke a hand edit",
"description": "Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.",
"description": "Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own. For a `chat-field` row it puts the site’s value back over the hand edit on the next sync.",
"parameters": [
{
"name": "id",
@@ -351,7 +351,7 @@
"Admin · Rust"
],
"summary": "Create or update a permission group",
"description": "Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.",
"description": "Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it. `chat` is the group’s BetterChat style: all twelve fields (`Priority`, `Title`, `TitleColor`, `TitleSize`, `TitleHidden`, `TitleHiddenIfNotPrimary`, `UsernameColor`, `UsernameSize`, `MessageColor`, `MessageSize`, `ChatFormat`, `ConsoleFormat`), each as text; `null` removes the style, which removes the group from BetterChat on the next sync; absent leaves it alone. A format must hold `{Message}` exactly once. A 400 carries one sentence per problem in `errors`.",
"parameters": [
{
"name": "name",
@@ -382,6 +382,9 @@
"scope": {
"example": "any"
},
"chat": {
"example": "any"
},
"title": {
"example": "any"
},
@@ -627,6 +630,44 @@
}
}
},
"/api/v1/admin/rust/servers/{id}/integrations": {
"get": {
"tags": [
"Admin · Rust"
],
"summary": "Which optional mods a server has loaded",
"description": "Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The server’s slug"
}
],
"responses": {
"200": {
"description": "What the server has loaded",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustIntegrations"
}
}
}
},
"404": {
"description": "No such server, or it is disabled"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/admin/rust/servers/{id}/map/fetch": {
"post": {
"tags": [
@@ -738,6 +779,50 @@
}
}
},
"/api/v1/admin/rust/servers/{id}/titles": {
"put": {
"tags": [
"Admin · Rust"
],
"summary": "Set a server’s chat titles",
"description": "Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by `kills`, `npckills` or `playtime` and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The server’s slug"
}
],
"responses": {
"200": {
"description": "Saved; answers the server’s settings as stored"
},
"400": {
"description": "A rule, the mode or N is not valid"
},
"404": {
"description": "No such server"
},
"500": {
"description": "Internal Server Error"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustTitleSettings"
}
}
}
}
}
},
"/api/v1/admin/rust/visibility": {
"get": {
"tags": [
@@ -766,7 +851,7 @@
"Admin · Rust"
],
"summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat",
"description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.",
"description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.",
"responses": {
"200": {
"description": "Saved; answers the new state",
@@ -800,6 +885,71 @@
}
}
},
"/api/v1/admin/rust/voice": {
"get": {
"tags": [
"Admin · Rust"
],
"summary": "The voice announcements are said in",
"description": "The permission group whose BetterChat style news and `rust.announce` lines are said in, or empty for plain chat, and every group that could be one. The line is composed on the site and said by the bridge plugin with no player as its sender, so it works whether or not BetterChat is loaded.",
"responses": {
"200": {
"description": "The voice and the groups that could be one",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustVoice"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
},
"put": {
"tags": [
"Admin · Rust"
],
"summary": "Choose the voice announcements are said in",
"description": "`group` is a permission group with a chat style, or empty for plain chat. A group without a style is refused.",
"responses": {
"200": {
"description": "Saved; answers the new state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustVoice"
}
}
}
},
"400": {
"description": "That group has no chat style"
},
"500": {
"description": "Internal Server Error"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"group": {
"type": "string",
"example": "staff"
}
}
}
}
}
}
}
},
"/api/v1/admin/users/{id}/rust/links": {
"get": {
"tags": [
@@ -1490,7 +1640,7 @@
"Public · Rust"
],
"summary": "The leaderboard for one Rust server",
"description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.",
"description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.",
"parameters": [
{
"name": "id",
@@ -2600,6 +2750,485 @@
},
"schedule": {
"$ref": "#/components/schemas/RustWipeSchedule"
},
"titles": {
"$ref": "#/components/schemas/RustTitleSettings"
},
"titlePush": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "What the last push of titles to this server found, since this site last started: how many players hold one and whether BetterChat was there to show them. Null before the first push."
},
"properties": {
"type": "object",
"properties": {
"count": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 4
}
}
},
"betterChat": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"at": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
}
}
}
}
}
}
}
}
}
}
},
"RustTitleSettings": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One server’s chat titles (phase 17, D135–D136): rules that rank the current wipe, in precedence order, and how many titles a player shows."
},
"properties": {
"type": "object",
"properties": {
"mode": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"first",
"all",
"upto"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "`first` shows the first rule a player meets, `all` every one, `upto` at most `max`."
},
"example": {
"type": "string",
"example": "first"
}
}
},
"max": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"minimum": {
"type": "number",
"example": 1
},
"maximum": {
"type": "number",
"example": 5
},
"example": {
"type": "number",
"example": 2
}
}
},
"rules": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"maxItems": {
"type": "number",
"example": 10
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"stat": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"kills",
"npckills",
"playtime"
],
"items": {
"type": "string"
}
},
"example": {
"type": "string",
"example": "kills"
}
}
},
"topN": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"minimum": {
"type": "number",
"example": 1
},
"maximum": {
"type": "number",
"example": 10
},
"example": {
"type": "number",
"example": 1
}
}
},
"text": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"maxLength": {
"type": "number",
"example": 24
},
"description": {
"type": "string",
"example": "The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own."
},
"example": {
"type": "string",
"example": "Top Killer"
}
}
},
"color": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "#ff8800"
}
}
}
}
}
}
}
}
}
}
}
}
},
"RustIntegrations": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Which optional mods a server has loaded right now (GET /admin/rust/servers/{id}/integrations), read live from the game."
},
"properties": {
"type": "object",
"properties": {
"ok": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"status": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "The sidecar client’s one-word status when the game could not be asked."
},
"example": {
"type": "string",
"example": "ok"
}
}
},
"integrations": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Null when the game could not be asked, or its plugin is older than protocol 12."
},
"properties": {
"type": "object",
"properties": {
"betterChat": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"loaded": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"version": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "5.2.15"
}
}
}
}
}
}
},
"popupNotifications": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"loaded": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"RustVoice": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "The voice news and `rust.announce` lines are said in (D140): a styled permission group, or none for plain chat."
},
"properties": {
"type": "object",
"properties": {
"voice": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "A group name, or empty for plain chat."
},
"example": {
"type": "string",
"example": "staff"
}
}
},
"options": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "Every group that has a chat style — the only groups that can be a voice."
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"group": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "staff"
}
}
},
"title": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "[Staff]"
}
}
},
"format": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "The line the voice makes, with `{message}` where the words go."
},
"example": {
"type": "string",
"example": "[#55aaff][+15][Staff][/+][/#] [#ffffff][+15]{message}[/+][/#]"
}
}
}
}
}
}
}
}
}
}
}
@@ -3547,6 +4176,49 @@
}
}
},
"chat": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "The group’s BetterChat style — all twelve fields as text — or null for a group without one (D138)."
},
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "object",
"properties": {
"Title": {
"type": "string",
"example": "[VIP]"
},
"TitleColor": {
"type": "string",
"example": "#ffaa55"
},
"ChatFormat": {
"type": "string",
"example": "{Title} {Username}: {Message}"
}
}
}
}
},
"members": {
"type": "object",
"properties": {
@@ -3849,6 +4521,90 @@
}
}
},
"chatFields": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "The twelve BetterChat group fields a style carries, with each one’s type and BetterChat’s default, for the style editor."
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "TitleColor"
}
}
},
"type": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"int",
"title",
"color",
"size",
"bool",
"format"
],
"items": {
"type": "string"
}
},
"example": {
"type": "string",
"example": "color"
}
}
},
"default": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "#55aaff"
}
}
}
}
}
}
}
}
},
"drift": {
"type": "object",
"properties": {
@@ -3905,7 +4661,7 @@
},
"description": {
"type": "string",
"example": "One of `grant`, `member`, `group-permission`."
"example": "One of `grant`, `member`, `group-permission`, or `chat-field` for a style field changed in game."
},
"example": {
"type": "string",
@@ -3913,6 +4669,27 @@
}
}
},
"detail": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "For `chat-field`, the value the game holds now. Null for every other kind."
},
"example": {
"type": "string",
"example": "#ff0000"
}
}
},
"subject": {
"type": "object",
"properties": {
@@ -5490,6 +6267,33 @@
"example": false
}
}
},
"delivery": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"chat",
"popup"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "Where the post goes when `on`: chat, or a popup — which needs PopupNotifications on that server (D142)."
},
"example": {
"type": "string",
"example": "chat"
}
}
}
}
}
@@ -7289,6 +8093,47 @@
}
}
},
"newsDelivery": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "A server id to where a news post goes on it: `chat` or `popup` (D142)."
},
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"chat",
"popup"
],
"items": {
"type": "string"
}
}
}
},
"example": {
"type": "object",
"properties": {
"main": {
"type": "string",
"example": "popup"
}
}
}
}
},
"map": {
"type": "object",
"properties": {