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

@@ -0,0 +1,175 @@
// ── A group's BetterChat style, and the voice made from one (phase 17) ─────
//
// D138 puts all twelve of BetterChat's group fields on a site-authored group,
// and D140 lets one styled group be the VOICE the module's own lines are said
// in. Both are pure text work, so they live here, without a database or a game:
//
// validateStyle what an operator typed → the twelve values BetterChat's own
// `chat group set` accepts, or one sentence per problem
// voiceFormat a style → the format string `chat.say` carries: BetterChat's
// markup with exactly one `{message}` in it
//
// Every value is TEXT, in the form BetterChat's setter parses — `true`/`false`,
// a decimal integer, a colour — because text is what the plugin compares the
// game's value against when it decides whether somebody edited a field by hand
// (§33.2). Two spellings of one value would be drift that never happened.
/** The longest a format may be (§33.4 reading 7). */
const FORMAT_MAX = 128
/** The longest a group's title may be. BetterChat has no limit; a chat line does. */
const TITLE_MAX = 64
/**
* The twelve fields, by the name BetterChat's API takes, each with its type and
* BetterChat 5.2.15's default. `Title`'s default is the group's own name in
* brackets, so it is worked out in `defaults`.
*/
const FIELDS = [
{ name: 'Priority', type: 'int', min: -9999, max: 9999, default: '0' },
{ name: 'Title', type: 'title', default: null },
{ name: 'TitleColor', type: 'color', default: '#55aaff' },
{ name: 'TitleSize', type: 'size', default: '15' },
{ name: 'TitleHidden', type: 'bool', default: 'false' },
{ name: 'TitleHiddenIfNotPrimary', type: 'bool', default: 'false' },
{ name: 'UsernameColor', type: 'color', default: '#55aaff' },
{ name: 'UsernameSize', type: 'size', default: '15' },
{ name: 'MessageColor', type: 'color', default: 'white' },
{ name: 'MessageSize', type: 'size', default: '15' },
{ name: 'ChatFormat', type: 'format', default: '{Title} {Username}: {Message}' },
{ name: 'ConsoleFormat', type: 'format', default: '{Title} {Username}: {Message}' },
]
const FIELD_NAMES = FIELDS.map((f) => f.name)
/** BetterChat's defaults for a new group of this name — what the form starts from. */
function defaults(group) {
const out = {}
for (const f of FIELDS) out[f.name] = f.default
out.Title = group === 'default' ? '[Player]' : `[${group}]`
return out
}
function occurrences(text, needle) {
return text.split(needle).length - 1
}
/**
* One field's value as BetterChat's setter takes it, or a sentence.
*
* Colours are `#rrggbb` or a plain colour word (BetterChat's own default for a
* message is `white`). A format holds `{Message}` EXACTLY once: without it every
* line a member types is swallowed, and twice cannot be made into a voice,
* whose line has one message.
*/
function checkField(field, raw) {
const text = typeof raw === 'boolean' || typeof raw === 'number' ? String(raw) : typeof raw === 'string' ? raw.trim() : null
if (text === null || /[\r\n]/.test(text)) return { error: `${field.name} must be text on one line` }
switch (field.type) {
case 'int': {
if (!/^-?\d{1,4}$/.test(text)) return { error: `${field.name} must be a whole number from ${field.min} to ${field.max}` }
return { value: String(Number(text)) }
}
case 'size': {
if (!/^\d{1,2}$/.test(text) || Number(text) < 6 || Number(text) > 64) return { error: `${field.name} must be a size from 6 to 64` }
return { value: String(Number(text)) }
}
case 'bool': {
const lowered = text.toLowerCase()
if (lowered !== 'true' && lowered !== 'false') return { error: `${field.name} must be true or false` }
return { value: lowered }
}
case 'color': {
if (/^#[0-9a-fA-F]{6}$/.test(text)) return { value: text.toLowerCase() }
if (/^[a-z]{3,20}$/.test(text)) return { value: text }
return { error: `${field.name} must be a colour like #ffaa55, or a colour word like white` }
}
case 'title': {
if (!text.length || text.length > TITLE_MAX) return { error: `Title must be 1 to ${TITLE_MAX} characters` }
// A brace would be read as a placeholder when the title is put into a line.
if (/[{}]/.test(text)) return { error: 'Title cannot contain { or }' }
return { value: text }
}
case 'format': {
if (text.length > FORMAT_MAX) return { error: `${field.name} must be at most ${FORMAT_MAX} characters` }
if (occurrences(text, '{Message}') !== 1) return { error: `${field.name} must contain {Message} exactly once` }
return { value: text }
}
default:
return { error: `${field.name} is not a field this site knows` }
}
}
/**
* An operator's style, checked whole. All twelve fields are required — a style
* is the whole of a BetterChat group or nothing (D138), which is what keeps a
* half-authored group from being a set of values nobody chose.
*
* Resolves `{ ok: true, fields }` with every value normalised, or
* `{ ok: false, errors }`, one sentence per problem.
*/
function validateStyle(style) {
if (!style || typeof style !== 'object' || Array.isArray(style)) {
return { ok: false, errors: ['a chat style is an object of the twelve BetterChat fields'] }
}
const errors = []
const fields = {}
for (const key of Object.keys(style)) {
if (!FIELD_NAMES.includes(key)) errors.push(`${key} is not a BetterChat group field`)
}
for (const field of FIELDS) {
if (style[field.name] === undefined || style[field.name] === null) {
errors.push(`${field.name} is missing`)
continue
}
const checked = checkField(field, style[field.name])
if (checked.error) errors.push(checked.error)
else fields[field.name] = checked.value
}
return errors.length ? { ok: false, errors } : { ok: true, fields }
}
/** A colour as BetterChat's markup writes it: the hex without its `#`, or the word. */
function markupColor(color) {
return String(color || 'white').replace(/^#/, '')
}
/**
* The format a styled group's voice says a line in (D140), or null.
*
* Built from six of the twelve fields (§33.4 reading 5): the title, its colour
* and size, the message's colour and size, and `ChatFormat`. The line has no
* sender, so `{Username}` renders as nothing — and so does the `:` BetterChat's
* own default puts after it, or every announcement would read `[Title] : …`.
* `{Group}`, `{ID}`, `{Time}` and `{Date}` render as nothing for the same
* reason. The plugin puts the words in for `{message}` and turns the markup into
* the game's rich text.
*/
function voiceFormat(fields) {
if (!fields || !fields.ChatFormat || occurrences(fields.ChatFormat, '{Message}') !== 1) return null
const title =
fields.TitleHidden === 'true'
? ''
: `[#${markupColor(fields.TitleColor)}][+${fields.TitleSize || 15}]${fields.Title || ''}[/+][/#]`
const message = `[#${markupColor(fields.MessageColor)}][+${fields.MessageSize || 15}]{message}[/+][/#]`
// split/join rather than `replace`, whose replacement string treats `$&` and
// friends as patterns — and a title is operator text.
const format = fields.ChatFormat
.replace(/\{Username\}\s*:?/g, '')
.replace(/\{(Group|ID|Time|Date)\}/g, '')
.split('{Title}').join(title)
.split('{Message}').join(message)
.replace(/\s{2,}/g, ' ')
.trim()
return occurrences(format, '{message}') === 1 ? format : null
}
module.exports = { FIELDS, FIELD_NAMES, FORMAT_MAX, TITLE_MAX, defaults, validateStyle, voiceFormat, checkField }

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,

View File

@@ -51,7 +51,7 @@ function inScope(scope, serverId) {
* trips per group or one join that repeats every group row once per member.
*/
async function overview() {
const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([
const [groups, groupPermissions, members, grants, sync, drift, catalogue, groupChat] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
@@ -59,9 +59,16 @@ async function overview() {
db.listSync(),
db.listDrift(),
db.listCatalogue(),
db.listGroupChat(),
])
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }]))
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [], chat: null }]))
// Phase 17: a group's BetterChat style, or null for a group without one.
for (const [name, fields] of chatByGroup(groupChat)) {
const group = byGroup.get(name)
if (group) group.chat = fields
}
for (const row of groupPermissions) {
const group = byGroup.get(row.groupName)
@@ -99,11 +106,23 @@ async function overview() {
groups: [...byGroup.values()],
grants: collapseGrants(grants),
servers: sync.map(shapeSync),
drift,
drift: drift.map((row) => ({ ...row, detail: row.detail === undefined ? null : row.detail })),
catalogue: catalogueByPermission(catalogue),
}
}
/** Style rows folded into one object per group: `name → { Field: value }`. */
function chatByGroup(rows) {
const out = new Map()
for (const row of rows || []) {
if (!out.has(row.groupName)) out.set(row.groupName, {})
out.get(row.groupName)[row.field] = row.value
}
return out
}
/**
* One row per grant, not one per linked account.
*
@@ -278,13 +297,14 @@ async function forPlayer(userId, steamIds, serverRows) {
* server is six times the queries for the same rows.
*/
async function readAuthored() {
const [groups, groupPermissions, members, grants, links, runGrants] = await Promise.all([
const [groups, groupPermissions, members, grants, links, runGrants, groupChat] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listLinks(),
db.listRunGrants(),
db.listGroupChat(),
])
const steamIdsByUser = new Map()
@@ -294,7 +314,7 @@ async function readAuthored() {
steamIdsByUser.get(link.userId).push(link.steamId)
}
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser }
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser, groupChat }
}
/**
@@ -421,6 +441,24 @@ function buildDesired(serverId, authored) {
}
}
// ── A group's BetterChat style (phase 17, D138) ───────────────────────
//
// One ledger row per FIELD (`chat-field`, subject the group, object the
// field), carrying its value: the diff that retires a style is the same
// `pushed − desired` as everything else, and the value is what the next sync
// sends as `expect`. The value is not in the row's identity — a changed value
// is the same field pushed again, not a retirement.
const chat = chatByGroup(authored.groupChat)
for (const group of scopedGroups) {
const fields = chat.get(group.name)
if (!fields) continue
for (const field of Object.keys(fields).sort()) {
rows.push({ kind: 'chat-field', subject: group.name, object: field, value: fields[field] })
}
}
const creditRows = [...credits.entries()]
.map(([key, count]) => {
const bar = key.indexOf('|')
@@ -435,6 +473,9 @@ function buildDesired(serverId, authored) {
rank: group.rank,
permissions: permissionsByGroup.get(group.name),
members: membersByGroup.get(group.name),
// The values only; `permSync` adds what each one expects to find, which
// is per server and comes from the ledger.
...(chat.has(group.name) ? { chat: chat.get(group.name) } : {}),
})),
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
steamId,
@@ -450,8 +491,11 @@ function buildDesired(serverId, authored) {
// Credits are in the digest, so a new reward or a revert pushes, but they are
// NOT in `rows`: those are the pushed ledger's, and a use of a kit is not
// something in the permission store to retire.
//
// A style field's VALUE goes into the digest the same way, since it is not in
// the row's identity: a colour changed on the site must push.
const hashed = [
...rows,
...rows.map((row) => (row.kind === 'chat-field' ? { ...row, object: `${row.object}=${row.value}` } : row)),
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
]
@@ -504,4 +548,5 @@ module.exports = {
rowKey,
collapseGrants,
shapeSync,
chatByGroup,
}

View File

@@ -0,0 +1,70 @@
// ── The voice the module's own lines are said in (phase 17, D140) ──────────
//
// One fleet setting (§33.4 reading 5): the name of a permission group that has
// a BetterChat style, or nothing for plain chat. News lines and `rust.announce`
// lines are then said in that group's title and colours — composed here, said by
// our plugin with no player as the sender, so they look right whether or not
// BetterChat is loaded.
//
// The style is read at the moment a line is said, never copied into the
// setting: an operator who recolours the group changes the voice with it, and a
// group whose style is taken away stops being a voice rather than leaving a
// stale one behind.
const settingsDb = require('../visibility/visibility.db')
const chatStyle = require('./chatStyle')
const db = require('./permissions.db')
const model = require('./permissions.model')
const VOICE_KEY = 'announce.voice'
/** The chosen group's name, or '' for plain chat. */
async function chosen() {
return (await settingsDb.getSetting(VOICE_KEY)) || ''
}
/**
* The format a line is said in right now, or null for plain chat — which is
* also the answer when the chosen group has since lost its style or gone.
*/
async function currentFormat() {
const group = await chosen()
if (!group) return null
const fields = await db.getGroupChat(group)
return fields ? chatStyle.voiceFormat(fields) : null
}
/** The setting, and every group that could be a voice, for the admin page. */
async function describe() {
const [voice, rows] = await Promise.all([chosen(), db.listGroupChat()])
const options = []
for (const [group, fields] of model.chatByGroup(rows)) {
const format = chatStyle.voiceFormat(fields)
if (format) options.push({ group, title: fields.Title || group, format })
}
return { voice, options: options.sort((a, b) => a.group.localeCompare(b.group)) }
}
/**
* Choose the voice. A group is accepted only when it has a style a voice can be
* made from; '' goes back to plain chat. Resolves `{ ok }` or
* `{ ok: false, message }`.
*/
async function choose(group, userId = null) {
const name = model.normaliseName(group)
if (name) {
const fields = await db.getGroupChat(name)
if (!fields || !chatStyle.voiceFormat(fields)) {
return { ok: false, message: `The group "${name}" has no chat style, so it cannot be a voice. Give it one under Permissions first.` }
}
}
await settingsDb.setSetting(VOICE_KEY, name, userId)
return { ok: true, voice: name }
}
module.exports = { VOICE_KEY, chosen, currentFormat, describe, choose }