// ── 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 }