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:
@@ -17,6 +17,7 @@
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const chatStyle = require('../../model/permissions/chatStyle')
|
||||
const db = require('../../model/permissions/permissions.db')
|
||||
const model = require('../../model/permissions/permissions.model')
|
||||
const permSync = require('../../permSync')
|
||||
@@ -27,7 +28,9 @@ const log = core.logger('admin:permissions')
|
||||
/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */
|
||||
async function overview(req, res) {
|
||||
try {
|
||||
res.json(await model.overview())
|
||||
// The twelve BetterChat fields travel with the model, so the form's editor
|
||||
// is built from the same list the server validates against (D138).
|
||||
res.json({ ...(await model.overview()), chatFields: chatStyle.FIELDS })
|
||||
} catch (err) {
|
||||
log.error('failed to read the permission model', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read the permission model' })
|
||||
@@ -50,6 +53,18 @@ async function putGroup(req, res) {
|
||||
return res.status(400).json({ message: 'That scope names no configured server' })
|
||||
}
|
||||
|
||||
// Phase 17: `chat` is the group's BetterChat style — all twelve fields, or
|
||||
// null to take the style away. Absent leaves it as it is, so a client that
|
||||
// predates styles cannot erase one by saving a group.
|
||||
let style
|
||||
if (req.body.chat !== undefined && req.body.chat !== null) {
|
||||
const checked = chatStyle.validateStyle(req.body.chat)
|
||||
if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
|
||||
style = checked.fields
|
||||
} else if (req.body.chat === null) {
|
||||
style = null
|
||||
}
|
||||
|
||||
const previous = await db.getGroup(name)
|
||||
|
||||
await db.upsertGroup({
|
||||
@@ -61,6 +76,7 @@ async function putGroup(req, res) {
|
||||
|
||||
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
|
||||
await db.setGroupPermissions(name, permissions)
|
||||
if (style !== undefined) await db.setGroupChat(name, style)
|
||||
|
||||
// Both scopes: a group that moved from one server to another has to be
|
||||
// retired from where it was as well as applied where it now is, and only the
|
||||
@@ -71,7 +87,12 @@ async function putGroup(req, res) {
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: previous ? 'rust.perm.group.update' : 'rust.perm.group.create',
|
||||
detail: { group: name, scope, permissions: permissions.length },
|
||||
detail: {
|
||||
group: name,
|
||||
scope,
|
||||
permissions: permissions.length,
|
||||
...(style !== undefined ? { chatStyle: style ? 'set' : 'removed' } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
return res.status(204).end()
|
||||
@@ -238,6 +259,8 @@ async function adoptDrift(req, res) {
|
||||
const row = await db.getDrift(id)
|
||||
if (!row) return res.status(404).json({ message: 'No such drift' })
|
||||
|
||||
if (row.kind === 'chat-field') return adoptStyleField(req, res, row)
|
||||
|
||||
if (row.kind !== 'grant' && row.kind !== 'member') {
|
||||
return res.status(400).json({
|
||||
message: 'Only a grant or a membership can be adopted. A permission on a group is edited on the group itself.',
|
||||
@@ -306,6 +329,8 @@ async function revokeDrift(req, res) {
|
||||
const row = await db.getDrift(id)
|
||||
if (!row) return res.status(404).json({ message: 'No such drift' })
|
||||
|
||||
if (row.kind === 'chat-field') return revokeStyleField(req, res, row)
|
||||
|
||||
await db.queueRevocation({
|
||||
serverId: row.serverId,
|
||||
kind: row.kind,
|
||||
@@ -330,6 +355,65 @@ async function revokeDrift(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a hand edit to a style field: the game's value becomes the site's.
|
||||
*
|
||||
* The style belongs to the GROUP, and a group may reach every server — so the
|
||||
* value adopted from one server is the value every server in its scope is
|
||||
* pushed next. That is what adopting means for a fleet-wide group, and the
|
||||
* activity row names the server it came from.
|
||||
*/
|
||||
async function adoptStyleField(req, res, row) {
|
||||
const style = await db.getGroupChat(row.subject)
|
||||
if (!style || style[row.object] === undefined) {
|
||||
return res.status(409).json({ message: 'That group has no chat style on this site to adopt the change into' })
|
||||
}
|
||||
|
||||
const field = chatStyle.FIELDS.find((f) => f.name === row.object)
|
||||
const checked = field ? chatStyle.checkField(field, row.detail === null ? '' : row.detail) : { error: 'unknown field' }
|
||||
if (checked.error) {
|
||||
return res.status(409).json({
|
||||
message: `The game's value cannot be adopted: ${checked.error}. Revoke it instead, or edit the style.`,
|
||||
})
|
||||
}
|
||||
|
||||
await db.setGroupChatField(row.subject, row.object, checked.value)
|
||||
// Already in that game, so already pushed there — the same reasoning as a grant.
|
||||
await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
|
||||
await db.deleteDrift(row.id)
|
||||
await db.markDirty(model.FLEET)
|
||||
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.perm.drift.adopt',
|
||||
detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object, value: checked.value },
|
||||
})
|
||||
|
||||
return res.status(204).end()
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a hand edit to a style field: put the site's value back.
|
||||
*
|
||||
* Not a queued revocation — there is nothing to remove, only a value to
|
||||
* overwrite. The ledger is told the game's value is this site's own, so the
|
||||
* next sync expects to find it and writes over it. That is a person choosing to
|
||||
* overwrite, which R2 allows (§33.2).
|
||||
*/
|
||||
async function revokeStyleField(req, res, row) {
|
||||
await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
|
||||
await db.deleteDrift(row.id)
|
||||
await db.markDirty(row.serverId)
|
||||
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.perm.drift.revoke',
|
||||
detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object },
|
||||
})
|
||||
|
||||
return res.status(202).json({ queued: true })
|
||||
}
|
||||
|
||||
/** Run the loop's pass now, for one server or for all of them, and report what happened. */
|
||||
async function syncNow(req, res) {
|
||||
const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null
|
||||
|
||||
Reference in New Issue
Block a user