feat(rust): notifications and engagement (phase 10, protocol 7)

Registers the engagement set R7 put in v1: thirteen triggers, four push
streams, three audiences, four bodies (two triggers, email and in-app)
and thirteen disabled rules in seven groups (PLAN.md §25, D59-D68).

The raid alert goes to everyone authorised on the tool cupboard, one
emit per linked person with ownerUserId, so the owner ceiling holds per
emit. It covers doors and walls (protocol 7), never names the raider,
alerts nobody when there is no cupboard, and carries ownerOnline so
"offline only" is the seeded rule's condition rather than code.

The fan-out runs off ingest before a frame is applied, since applying a
disband deletes the roster the notice is sent to. A replayed event is
told only while it is news: 15 minutes for broadcasts, 24 hours for
personal and staff events. Dedupe keys come from the event, not the
sidecar's row id. Server online/offline and a new kills leader are
in-memory transitions, never on first sight, and a tie is not a lead.
A login with no approval within a minute becomes a staff notice via a
query, so a restart loses nothing.

Also fixes a phase-4 gap (D68): the refresh now asks /health, so a game
that hung, or whose bridge was unloaded, while the sidecar stayed up no
longer reads as online. It stops naming players as online, and a stale
board no longer moves "last seen".

engagement-triggers.json is the committed freeze of all of it, checked
in CI with line endings normalised. The check was verified by breaking
it both ways.

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-23 06:06:08 -05:00
parent dc3c9689b4
commit 285db0baa7
22 changed files with 3256 additions and 32 deletions

View File

@@ -0,0 +1,107 @@
// ── Named sets of people, over this module's own data ─────────────────────
//
// `registerAudiences` (MODULE_API.md §2.4; PLAN.md §25.2). An operator points a
// rule or a segment at one of these; core calls `resolve` when a rule fires.
//
// Three properties, each the contract rather than a style:
//
// • **A resolver returns website user ids and nothing else.** Never an
// address, a channel or a Steam id: core maps ids to people after the
// preferences, the suppression list and the verification gate, and a module
// that could hand it anything else would have a way to send mail.
// • **One that fails answers NOBODY** — never everybody, never its last good
// answer. A throw here is caught and returned as `[]`, and core treats a
// throw the same way; both are here so the property does not rest on
// either side alone.
// • **Params are constants**, fixed when an operator saves the rule. "The clan
// this event was about" is therefore not expressible as an audience — a
// clan trigger carries its own recipients instead (`emit.js`).
const core = require('../core')
const log = core.logger('audiences')
const LINKS = 'rust_account_links'
/** Wraps a resolver so a failure is an empty set, logged, and never a throw. */
function safe(id, fn) {
return async (params) => {
try {
const rows = await fn(params || {})
return rows.map((r) => Number(r.userId)).filter((n) => Number.isInteger(n) && n > 0)
} catch (err) {
log.warn('an audience could not be resolved; it answers nobody', { audience: id, error: err.message })
return []
}
}
}
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null)
const AUDIENCES = Object.freeze([
{
id: 'rust.clan.members',
label: 'Members of a clan',
params: [{ id: 'clan', type: 'string', required: true }],
ceiling: 'members',
// A clan's LINKED members, as the store holds them now. A clan that has
// been disbanded has no roster, so a rule saved against it resolves to
// nobody — which is the truth, and not the same as the audience being gone.
resolve: safe('rust.clan.members', async ({ clan }) => {
const key = text(clan)
if (!key) return []
return core.query(
`SELECT DISTINCT l.user_id AS userId
FROM rust_clan_members m
JOIN rust_clans c ON c.external_id = m.external_id AND c.gone_at IS NULL
JOIN ${LINKS} l ON l.steam_id = m.steam_id
WHERE m.external_id = ?`,
[key],
)
}),
},
{
id: 'rust.server.players',
label: 'Everyone who has played on a server',
params: [{ id: 'serverId', type: 'string', required: true }],
ceiling: 'authenticated',
// Linked accounts with a stats row on this server in ANY wipe. The stats
// table is the record of having played, and it outlives both wipes and the
// raw event history (R12).
resolve: safe('rust.server.players', async ({ serverId }) => {
const id = text(serverId)
if (!id) return []
return core.query(
`SELECT DISTINCT l.user_id AS userId
FROM rust_player_wipe_stats s
JOIN ${LINKS} l ON l.steam_id = s.steam_id
WHERE s.server_id = ?`,
[id],
)
}),
},
{
id: 'rust.wipe.participants',
label: 'Everyone playing a server\'s current wipe',
params: [{ id: 'serverId', type: 'string', required: true }],
ceiling: 'authenticated',
// The same, narrowed to the wipe the server is on NOW. Resolved at send
// time, so a rule saved last month reaches this month's players — which is
// what "current" has to mean for a parameter fixed when the rule was saved.
// A server with no known wipe resolves to nobody rather than to every wipe.
resolve: safe('rust.wipe.participants', async ({ serverId }) => {
const id = text(serverId)
if (!id) return []
return core.query(
`SELECT DISTINCT l.user_id AS userId
FROM rust_server_state st
JOIN rust_player_wipe_stats s ON s.server_id = st.server_id AND s.wipe_id = st.wipe_id
JOIN ${LINKS} l ON l.steam_id = s.steam_id
WHERE st.server_id = ? AND st.wipe_id IS NOT NULL`,
[id],
)
}),
},
])
module.exports = { AUDIENCES }