feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 10m49s

The same Team event as §6, delivered a third time: push, email, and now a
Discord channel the operator configured. Not a second pipeline — teamNotify.js
already computed the recipient set once, so the bridge is a sink beside the two
that were there.

The design's gate has no data source. §7.2 bridges an event only if "its
visibility is public, or its destination channel is configured for a
members-only Team context". The four team.* streams carry no visibility; forum
threads have no public/members column because a forum is members-only by
construction; and core cannot see a Discord channel's permissions. So §7.2's own
example config names exactly the two events that are never public.

The gate is therefore an attributed operator acknowledgement, in the shape
teams_forum_uploads_ack already uses. It is a precondition — 422, not a quiet
drop at delivery — it is re-asked at delivery as well as at the save, and
changing the channel clears it, because an acknowledgement is about a
destination and cannot survive the destination changing underneath it.

The design's DDL cannot hold its own default row: MariaDB coerces every PRIMARY
KEY column to NOT NULL, so `team_id NULL` — the deployment-wide default every
override overrides — is unrepresentable. Proved on a real MariaDB (error 1048).
Replaced with a surrogate id, a generated team_key AS IFNULL(team_id, 0) in the
unique key, and the foreign key the original had no room for.

One-shot, not queued: "identical to announce and mod-reverse" names two
different reliability models, and a Team notification is the moment it
describes.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 20:25:30 -05:00
parent 46f43a5fd6
commit 11b4368b57
25 changed files with 2567 additions and 11 deletions

View File

@@ -0,0 +1,120 @@
// ── 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<boolean>} 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/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 }