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
316 lines
11 KiB
JavaScript
316 lines
11 KiB
JavaScript
// ── What the notifications read like, and the rules that use them ─────────
|
|
//
|
|
// `registerEngagementSeeds` (MODULE_API.md §2.4 and §1.1 under 1.9.0; PLAN.md
|
|
// §25.2). Data only: nothing here names a recipient, and nothing here turns a
|
|
// rule on.
|
|
//
|
|
// ── Two bespoke bodies, and why only two ──────────────────────────────────
|
|
//
|
|
// A body earns its place when the message has something to say that core's
|
|
// structural projection cannot. The raid alert does — it is the one message
|
|
// here somebody acts on at 3am, and it must say WHERE and WHAT in the first
|
|
// line. The wipe does — it is the one broadcast a whole community waits for.
|
|
// Everything else is "this happened, here is the link", which is exactly what
|
|
// core's `notify.event` / `inapp.event` already say, so it points at those and
|
|
// authors nothing (§4.6.1 property 1).
|
|
//
|
|
// The register is plain, not in-universe. Rust has no court or herald to write
|
|
// in the voice of, and a raid alert dressed as fiction is a raid alert read a
|
|
// second later than it should be.
|
|
//
|
|
// ── Three rules for editing a body ────────────────────────────────────────
|
|
//
|
|
// 1. **No conditionals, and never an optional inside a clause.** An unset
|
|
// optional interpolates to the EMPTY STRING. `atGrid` is a fragment that
|
|
// carries its own leading space for exactly that reason; `grid` on its own
|
|
// belongs on a line of its own or nowhere.
|
|
// 2. **No brand.** `siteName` and friends are supplied by the renderer, so one
|
|
// image mails as whichever site it is running as.
|
|
// 3. **Bump `seedVersion` when a body changes, never for a comment.** It is how
|
|
// a better default reaches deployments whose operators did not edit it.
|
|
//
|
|
// ── One rule group per family ─────────────────────────────────────────────
|
|
//
|
|
// A group is seeded ONCE (per deployment, per key), so a rule appended to a
|
|
// group in a later version reaches fresh installs only. Seven families, seven
|
|
// keys: a future raid rule takes `raid-v2` without disturbing anybody's clan
|
|
// rules. Every rule is disabled — core ignores `enabled` rather than trusting it
|
|
// — so installing this module mails nobody until an operator decides it should.
|
|
|
|
// ── Block helpers ──────────────────────────────────────────────────────────
|
|
|
|
const text = (id, body, opts = {}) => ({
|
|
id,
|
|
type: 'email.text',
|
|
props: opts.muted ? { text: body, muted: true } : { text: body },
|
|
})
|
|
const heading = (id, body, level = 'h1') => ({ id, type: 'email.heading', props: { level, text: body } })
|
|
const button = (id, label, url, textLead) => ({
|
|
id,
|
|
type: 'email.button',
|
|
props: textLead ? { label, url, textLead } : { label, url },
|
|
})
|
|
const divider = (id) => ({ id, type: 'email.divider', props: {} })
|
|
|
|
// Every email ends with the unsubscribe pair; `unsubscribeUrl` is core's
|
|
// per-delivery variable, not something a trigger declares.
|
|
const unsubscribe = () => [
|
|
divider('rule'),
|
|
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these messages, use this link:'),
|
|
]
|
|
|
|
const email = (key, name, triggerId, subject, blocks) => ({
|
|
key,
|
|
name,
|
|
channel: 'email',
|
|
triggerId,
|
|
triggerVersion: 1,
|
|
seedVersion: 1,
|
|
subject,
|
|
blocks: [...blocks, ...unsubscribe()],
|
|
})
|
|
|
|
/** In-app: heading = the row's title, button = its one action, the rest = its body. */
|
|
const inapp = (key, name, triggerId, title, body, action, url) => ({
|
|
key,
|
|
name,
|
|
channel: 'inapp',
|
|
triggerId,
|
|
triggerVersion: 1,
|
|
seedVersion: 1,
|
|
subject: null,
|
|
blocks: [heading('h', title, 'h3'), text('intro', body), button('cta', action, url)],
|
|
})
|
|
|
|
const TEMPLATES = Object.freeze([
|
|
email(
|
|
'rust.base.destroyed',
|
|
'Rust — your base was raided',
|
|
'rust.base.destroyed',
|
|
'Your base on {{server}} is being raided',
|
|
[
|
|
heading('h', 'Your base is being raided'),
|
|
text('p1', 'A {{structure}} of a base you are authorised on was destroyed{{atGrid}} on {{server}}.'),
|
|
text('p2',
|
|
'You are getting this because you are on the base\'s tool cupboard. Further damage to the '
|
|
+ 'same base will not send another alert for a while.', { muted: true }),
|
|
button('cta', 'Open the server page', '{{serverUrl}}'),
|
|
],
|
|
),
|
|
inapp(
|
|
'rust.base.destroyed-inapp',
|
|
'Rust — your base was raided (in-app)',
|
|
'rust.base.destroyed',
|
|
'Your base is being raided',
|
|
'A {{structure}} was destroyed{{atGrid}} on {{server}}.',
|
|
'Open the server',
|
|
'{{serverUrl}}',
|
|
),
|
|
email(
|
|
'rust.wipe.started',
|
|
'Rust — a server wiped',
|
|
'rust.wipe.started',
|
|
'{{server}} has wiped',
|
|
[
|
|
heading('h', '{{server}} has wiped'),
|
|
text('p1', 'A new wipe has started on {{server}}: a fresh map, and a fresh start for everyone.'),
|
|
button('cta', 'Open the server page', '{{serverUrl}}'),
|
|
],
|
|
),
|
|
inapp(
|
|
'rust.wipe.started-inapp',
|
|
'Rust — a server wiped (in-app)',
|
|
'rust.wipe.started',
|
|
'{{server}} has wiped',
|
|
'A new wipe has started: a fresh map, and a fresh start for everyone.',
|
|
'Open the server',
|
|
'{{serverUrl}}',
|
|
),
|
|
])
|
|
|
|
// ── The rules — every one of them off ──────────────────────────────────────
|
|
|
|
/** Core's generic bodies (§4.6.1 property 1). */
|
|
const GENERIC = { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' }
|
|
|
|
/** This module's bodies for a trigger, and core's digest. */
|
|
const bodies = (key) => ({ email: key, inapp: `${key}-inapp`, digest: 'notify.digest' })
|
|
|
|
const RULE_GROUPS = Object.freeze([
|
|
{
|
|
key: 'raid-v1',
|
|
note: 'module-rust: the raid alert (disabled)',
|
|
rules: [
|
|
{
|
|
trigger_id: 'rust.base.destroyed',
|
|
name: 'Raid alert — offline owners',
|
|
audience: 'owner',
|
|
// Push is allowed because this trigger is also a stream (D65); the
|
|
// tickle carries no content, and the app pulls the inbox row.
|
|
channels: ['email', 'inapp', 'push'],
|
|
template_keys: bodies('rust.base.destroyed'),
|
|
// Per BUILDING (the subjectKey): a raid is dozens of walls and one alert.
|
|
cooldown_seconds: 1800,
|
|
max_sends_per_hour: 500,
|
|
// D61: "offline raid alert" is this condition, not code. An operator who
|
|
// wants online raids too deletes it.
|
|
conditions: { variable: 'ownerOnline', cmp: 'eq', value: false },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'wipe-v1',
|
|
note: 'module-rust: wipe announcements (disabled)',
|
|
rules: [
|
|
{
|
|
trigger_id: 'rust.wipe.started',
|
|
name: 'Server wiped',
|
|
audience: 'subscribers',
|
|
channels: ['email', 'inapp', 'push'],
|
|
template_keys: bodies('rust.wipe.started'),
|
|
cooldown_seconds: 6 * 3600,
|
|
max_sends_per_hour: 2000,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'server-v1',
|
|
note: 'module-rust: server up and down (disabled)',
|
|
rules: [
|
|
{
|
|
trigger_id: 'rust.server.online',
|
|
name: 'Server came online',
|
|
audience: 'subscribers',
|
|
channels: ['inapp', 'push'],
|
|
template_keys: { inapp: GENERIC.inapp },
|
|
cooldown_seconds: 3600,
|
|
max_sends_per_hour: 2000,
|
|
},
|
|
{
|
|
trigger_id: 'rust.server.offline',
|
|
name: 'Server went offline',
|
|
audience: 'subscribers',
|
|
channels: ['inapp', 'push'],
|
|
template_keys: { inapp: GENERIC.inapp },
|
|
cooldown_seconds: 3600,
|
|
max_sends_per_hour: 2000,
|
|
// A plugin reload, or a restart that is back within five minutes, is not
|
|
// an outage anybody needs to hear about. `cancel_on` withdraws the
|
|
// pending notice when the server comes back inside the window.
|
|
delay_seconds: 300,
|
|
cancel_on: ['rust.server.online'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'leaderboard-v1',
|
|
note: 'module-rust: a new kills leader (disabled)',
|
|
rules: [
|
|
{
|
|
trigger_id: 'rust.leaderboard.topped',
|
|
name: 'New kills leader',
|
|
audience: 'subscribers',
|
|
channels: ['inapp'],
|
|
template_keys: { inapp: GENERIC.inapp },
|
|
cooldown_seconds: 3600,
|
|
max_sends_per_hour: 2000,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'account-v1',
|
|
note: 'module-rust: a Steam account was linked (disabled)',
|
|
rules: [
|
|
{
|
|
trigger_id: 'rust.player.linked',
|
|
name: 'Steam account linked',
|
|
audience: 'owner',
|
|
// Email as well as in-app: the case this exists for is a link the person
|
|
// did NOT make, and they will not be looking at the site's inbox for it.
|
|
channels: ['email', 'inapp'],
|
|
template_keys: GENERIC,
|
|
cooldown_seconds: 0,
|
|
max_sends_per_hour: 200,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'clans-v1',
|
|
note: 'module-rust: clan departures and disbands (disabled)',
|
|
rules: [
|
|
{
|
|
trigger_id: 'rust.clan.member.left',
|
|
name: 'Clan — a member left',
|
|
audience: 'members',
|
|
channels: ['inapp'],
|
|
template_keys: { inapp: GENERIC.inapp },
|
|
cooldown_seconds: 0,
|
|
max_sends_per_hour: 500,
|
|
},
|
|
{
|
|
trigger_id: 'rust.clan.member.kicked',
|
|
name: 'Clan — a member was removed',
|
|
audience: 'members',
|
|
channels: ['inapp'],
|
|
template_keys: { inapp: GENERIC.inapp },
|
|
cooldown_seconds: 0,
|
|
max_sends_per_hour: 500,
|
|
},
|
|
{
|
|
trigger_id: 'rust.clan.disbanded',
|
|
name: 'Clan — disbanded',
|
|
audience: 'members',
|
|
channels: ['email', 'inapp'],
|
|
template_keys: GENERIC,
|
|
cooldown_seconds: 0,
|
|
max_sends_per_hour: 500,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'moderation-v1',
|
|
note: 'module-rust: reports, bans and unapproved logins, to staff (disabled)',
|
|
rules: [
|
|
{
|
|
trigger_id: 'rust.player.reported',
|
|
name: 'Player reported',
|
|
audience: 'staff',
|
|
channels: ['email', 'inapp'],
|
|
template_keys: GENERIC,
|
|
// Per REPORTED player: a pile-on of ten reports is one notice an hour.
|
|
cooldown_seconds: 3600,
|
|
max_sends_per_hour: 200,
|
|
},
|
|
{
|
|
trigger_id: 'rust.player.banned',
|
|
name: 'Player banned',
|
|
audience: 'staff',
|
|
channels: ['inapp'],
|
|
template_keys: { inapp: GENERIC.inapp },
|
|
cooldown_seconds: 0,
|
|
max_sends_per_hour: 200,
|
|
},
|
|
{
|
|
trigger_id: 'rust.player.unbanned',
|
|
name: 'Player unbanned',
|
|
audience: 'staff',
|
|
channels: ['inapp'],
|
|
template_keys: { inapp: GENERIC.inapp },
|
|
cooldown_seconds: 0,
|
|
max_sends_per_hour: 200,
|
|
},
|
|
{
|
|
trigger_id: 'rust.login.denied',
|
|
name: 'Login not approved',
|
|
audience: 'staff',
|
|
channels: ['inapp'],
|
|
template_keys: { inapp: GENERIC.inapp },
|
|
cooldown_seconds: 3600,
|
|
max_sends_per_hour: 200,
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
module.exports = { TEMPLATES, RULE_GROUPS }
|