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
108 lines
4.3 KiB
JavaScript
108 lines
4.3 KiB
JavaScript
// ── 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 }
|