// ── The integration bridge: the same Team event, a second delivery ───────── // // TEAMS.md §7.2, phase 8. §6 gave a Team event two sinks — a content-free push // tickle and, for forum content, an email. This is the third, and it is // deliberately NOT a second pipeline: `teamNotify.js` computes the recipient set // once, and the event it already has in hand is handed here on the way out. // // **A Discord message carries content; a push tickle does not**, and the two look // like the same event only from far away. ntfy is an untrusted relay reached by an // unguessable topic, so the tickle is content-free and the app pulls the real // thing over the authenticated API. A Discord channel is an operator-configured, // trusted destination where "something happened, go look" would be useless — and, // crucially, there is no app on the other end to do the pulling. So core composes // the text here. // // **Composing that text is core's to do, unlike an activity summary.** §4.1 forbids // core phrasing a `team_activity` line because the vocabulary is the module's. This // is the opposite case: these are core's own four notification streams, about core's // own forum and core's own membership projection, and core already composes the // email body for exactly the same events (§6.4). Nothing here names a game concept. // // **Nothing in this file throws.** Same contract as the file that calls it: the // forum reply is written and answered before any of this runs, and a courtesy that // can fail the transaction behind it is a defect. // // **One-shot, not queued.** `announce` earns its retry/backoff because a news post // is a durable artifact whose Discord copy is expected to exist; a Team // notification is the moment it describes. A message that arrives twenty minutes // after the conversation has moved on is worse than one that never arrives, and a // second job table plus a second worker is a lot of machinery to buy that. A bot // that is down drops the message and the site is unaffected — which is the same // deal the push tickle takes. const botInternalClient = require('./botInternalClient') const teamIntegration = require('../model/teams/teamIntegration.model') const log = require('./logger')('team-bridge') // How much of a post body a Discord embed carries. Longer than the email's 200 — // an embed description holds 4096 characters and a channel is a place people skim // — but still an excerpt, because the point is to get someone to open the thread. const EXCERPT_CHARS = 400 /** * Deliver one event, if this Team's configuration asks for it. * * The access decision is `destinationFor`'s and it has already re-checked the * members-only acknowledgement against the live row, so by the time anything is * composed here the operator has said this channel may hold it. * * @returns {Promise} whether a message was handed to the bot. False is * the ordinary answer on a deployment with no bridge configured, which is most * of them — it is not an error and is not logged as one. */ async function deliver(streamId, team, content = {}) { try { if (!team || !team.id) return false const destination = await teamIntegration.destinationFor(team.id, streamId) if (!destination) return false const res = await botInternalClient.teamNotify({ channelId: destination.channelRef, streamId, teamName: teamLabel(team), teamUrl: content.teamUrl || null, title: content.title || null, body: content.body || null, url: content.url || null, }) if (!res || !res.ok) { // Warn, not error, and then stop. There is nothing to retry against and // nothing downstream that needs to know: the push and email sinks have // already run and neither depends on this one. log.warn('bridge delivery failed', { teamId: team.id, streamId, status: res && res.status, error: res && res.error, }) return false } return true } catch (err) { log.warn('bridge delivery threw', { teamId: team && team.id, streamId, message: err.message }) return false } } const teamLabel = (team) => (team && (team.display_name_override || team.name)) || 'a team' /** Markup out, whitespace collapsed, truncated — the embed description is text. */ function excerpt(html) { const text = String(html || '') .replace(/<[^>]*>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/\s+/g, ' ') .trim() return text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text } /** * "3 new members joined." — a count, and never a name. * * The roster sync notifies once per run rather than once per member (§6.2), so a * count is all the caller has; it is also all this should say. A member's * character name is game-sourced text that has been through core's reserved-name * screening for a PAGE, not for a channel, and the roster it comes from is on a * public page anybody in that channel can already open. */ function memberJoinedBody(count) { const n = Number(count) || 0 if (n <= 0) return 'The roster has changed.' return n === 1 ? 'A new member joined.' : `${n} new members joined.` } module.exports = { deliver, excerpt, teamLabel, memberJoinedBody, EXCERPT_CHARS }