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

@@ -27,6 +27,7 @@ const core = require('../../core')
const GROUPS = 'rust_perm_groups'
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
const GROUP_MEMBERS = 'rust_perm_group_members'
const GROUP_CHAT = 'rust_perm_group_chat'
const GRANTS = 'rust_perm_grants'
const RUN_GRANTS = 'rust_perm_run_grants'
const PUSHED = 'rust_perm_pushed'
@@ -102,6 +103,43 @@ async function setGroupPermissions(name, permissions) {
)
}
/** Every group's BetterChat style, one row per field (phase 17, D138). */
async function listGroupChat() {
return core.query(
`SELECT group_name AS groupName, field, value FROM ${GROUP_CHAT} ORDER BY group_name ASC, field ASC`,
)
}
/**
* Replace a group's style whole, or remove it with `null`. A style is all twelve
* fields or none, and the form edits it as one thing.
*/
async function setGroupChat(name, fields) {
await core.query(`DELETE FROM ${GROUP_CHAT} WHERE group_name = ?`, [name])
const entries = fields ? Object.entries(fields) : []
if (!entries.length) return
await core.query(
`INSERT INTO ${GROUP_CHAT} (group_name, field, value) VALUES ${placeholders(entries, 3)}`,
entries.flatMap(([field, value]) => [name, field, value]),
)
}
/** One field of a style, for adopting a hand edit. Returns whether the group has that field. */
async function setGroupChatField(name, field, value) {
const result = await core.query(
`UPDATE ${GROUP_CHAT} SET value = ? WHERE group_name = ? AND field = ?`,
[value, name, field],
)
return Number(result.affectedRows || 0) > 0
}
async function getGroupChat(name) {
const rows = await core.query(`SELECT field, value FROM ${GROUP_CHAT} WHERE group_name = ?`, [name])
return rows.length ? Object.fromEntries(rows.map((r) => [r.field, r.value])) : null
}
/**
* Every membership, with the member's Steam accounts joined on.
*
@@ -328,21 +366,38 @@ async function listPushedForSteamIds(steamIds) {
async function listPushed(serverId) {
return core.query(
`SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`,
`SELECT kind, subject, object, value FROM ${PUSHED} WHERE server_id = ?`,
[serverId],
)
}
/**
* Record rows as landed. A `chat-field` row carries the VALUE that landed, and a
* second landing of the same field moves it: the value is what the next sync
* tells a hand edit from this site's own write by (§33.2). Every other kind has
* no value and is written once.
*/
async function addPushed(serverId, rows) {
if (!rows.length) return
await core.query(
`INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object)
VALUES ${placeholders(rows, 4)}`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
`INSERT INTO ${PUSHED} (server_id, kind, subject, object, value)
VALUES ${placeholders(rows, 5)}
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object, row.value === undefined ? null : row.value]),
)
}
/**
* Say that a style field holds `value` in one game as far as this site is
* concerned. It is how a person revokes a hand edit to a style: the next sync
* sends the site's value with this as what it expects to find, which is the
* game's own value — so the plugin writes over it, on purpose (§33.2).
*/
async function setPushedValue(serverId, { kind, subject, object, value }) {
await addPushed(serverId, [{ kind, subject, object, value }])
}
async function removePushed(serverId, rows) {
for (const row of rows) {
// eslint-disable-next-line no-await-in-loop
@@ -367,10 +422,10 @@ async function replaceDrift(serverId, rows) {
}
await core.query(
`INSERT INTO ${DRIFT} (server_id, kind, subject, object)
VALUES ${placeholders(rows, 4)}
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
`INSERT INTO ${DRIFT} (server_id, kind, subject, object, detail)
VALUES ${placeholders(rows, 5)}
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP, detail = VALUES(detail)`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object, row.detail === undefined ? null : row.detail]),
)
// Anything this report did NOT name is gone from the game, so it goes from
@@ -387,7 +442,7 @@ async function replaceDrift(serverId, rows) {
async function listDrift() {
return core.query(
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object,
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object, d.detail,
d.first_seen AS firstSeen, d.last_seen AS lastSeen,
l.user_id AS userId, u.username, p.name AS playerName
FROM ${DRIFT} d
@@ -400,7 +455,7 @@ async function listDrift() {
async function getDrift(id) {
const rows = await core.query(
`SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`,
`SELECT id, server_id AS serverId, kind, subject, object, detail FROM ${DRIFT} WHERE id = ?`,
[id],
)
@@ -543,6 +598,10 @@ module.exports = {
deleteGroup,
listGroupPermissions,
setGroupPermissions,
listGroupChat,
setGroupChat,
setGroupChatField,
getGroupChat,
listGroupMembers,
addGroupMember,
removeGroupMember,
@@ -561,6 +620,7 @@ module.exports = {
listPushedForSteamIds,
listPushed,
addPushed,
setPushedValue,
removePushed,
replaceDrift,
listDrift,