feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
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:
@@ -84,4 +84,21 @@ function refreshCommands() {
|
||||
return call('/internal/refresh-commands', { method: 'POST', body: {} })
|
||||
}
|
||||
|
||||
module.exports = { pushConfig, getStatus, announce, reverseModAction, refreshCommands }
|
||||
// Site -> bot: a Team notification the operator has configured a channel for
|
||||
// (TEAMS.md §7.2). Best-effort and one-shot, unlike `announce`: a news post is a
|
||||
// durable artifact whose Discord copy is expected to exist, so it rides the
|
||||
// announce_jobs retry; a Team notification is the moment it describes, and a
|
||||
// message that lands twenty minutes late is worse than one that never lands.
|
||||
//
|
||||
// The channel is chosen by the SITE and passed in, not looked up by the bot from
|
||||
// guild_config the way `announce` finds #news. Which channel a Team's events go
|
||||
// to is per-Team configuration that lives in team_integration_config, and a bot
|
||||
// that resolved it would need a second copy of that table.
|
||||
function teamNotify({ channelId, streamId, teamName, teamUrl, title, body, url }) {
|
||||
return call('/internal/team-notify', {
|
||||
method: 'POST',
|
||||
body: { channel_id: channelId, stream: streamId, team_name: teamName, team_url: teamUrl, title, body, url },
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { pushConfig, getStatus, announce, reverseModAction, refreshCommands, teamNotify }
|
||||
|
||||
120
server/src/utils/teamBridge.js
Normal file
120
server/src/utils/teamBridge.js
Normal 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(/ /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 }
|
||||
@@ -17,6 +17,14 @@
|
||||
// is a destination the recipient chose rather than a relay (§6.4). The asymmetry
|
||||
// is the security model, not an inconsistency to tidy up.
|
||||
//
|
||||
// **Phase 8 added a THIRD sink, and it is a second delivery rather than a second
|
||||
// pipeline.** `utils/teamBridge.js` takes the same event, already computed, and
|
||||
// hands it to a Discord channel the operator configured — which is why every
|
||||
// entry point below calls it beside the tickle instead of anything re-deriving
|
||||
// the event. Note that the bridge does NOT take the recipient set: its audience
|
||||
// is whoever can read a channel, which is why enabling it for members-only
|
||||
// content needs an operator acknowledgement (§7.2, teamIntegration.model.js).
|
||||
//
|
||||
// **Roster events are push-only, and forum events are the only ones that email.**
|
||||
// §6.4's argument for the email sink is the web-only user who never learns that
|
||||
// someone replied to their own thread. "Someone joined the guild" is not that: it
|
||||
@@ -26,6 +34,7 @@
|
||||
// the file that says so.
|
||||
|
||||
const pushDispatch = require('./pushDispatch')
|
||||
const teamBridge = require('./teamBridge')
|
||||
const teamNotify = require('../model/teams/teamNotify.model')
|
||||
const forumSettings = require('../model/teams/teamForumSettings.model')
|
||||
const mailer = require('./mailer')
|
||||
@@ -119,9 +128,21 @@ async function tickle(streamId, team, { ref, exclude = [] } = {}) {
|
||||
// No `memberName` argument, and that is the point: a tickle is content-free, so
|
||||
// there is nothing about WHO joined for this function to carry. The name is on
|
||||
// the activity feed the app pulls after waking.
|
||||
async function memberJoined(team) {
|
||||
//
|
||||
// `count` is phase 8's one addition and it is for the BRIDGE, not the tickle: a
|
||||
// Discord channel has no app on the other end to pull anything, so the message
|
||||
// has to say something, and "3 new members joined" is the most a caller that
|
||||
// notifies once per sweep can honestly say. Optional, so the sync is the only
|
||||
// caller that has to know it exists.
|
||||
async function memberJoined(team, { count } = {}) {
|
||||
try {
|
||||
return await tickle(STREAMS.MEMBER_JOINED, team, { ref: `team:${team.id}` })
|
||||
const sent = await tickle(STREAMS.MEMBER_JOINED, team, { ref: `team:${team.id}` })
|
||||
await teamBridge.deliver(STREAMS.MEMBER_JOINED, team, {
|
||||
body: teamBridge.memberJoinedBody(count),
|
||||
teamUrl: teamPageUrl(team),
|
||||
url: teamPageUrl(team),
|
||||
})
|
||||
return sent
|
||||
} catch (err) {
|
||||
log.warn('member-joined notification failed', { teamId: team && team.id, message: err.message })
|
||||
return 0
|
||||
@@ -130,7 +151,13 @@ async function memberJoined(team) {
|
||||
|
||||
async function leadershipChanged(team) {
|
||||
try {
|
||||
return await tickle(STREAMS.LEADERSHIP_CHANGED, team, { ref: `team:${team.id}` })
|
||||
const sent = await tickle(STREAMS.LEADERSHIP_CHANGED, team, { ref: `team:${team.id}` })
|
||||
await teamBridge.deliver(STREAMS.LEADERSHIP_CHANGED, team, {
|
||||
body: 'Leadership has changed.',
|
||||
teamUrl: teamPageUrl(team),
|
||||
url: teamPageUrl(team),
|
||||
})
|
||||
return sent
|
||||
} catch (err) {
|
||||
log.warn('leadership notification failed', { teamId: team && team.id, message: err.message })
|
||||
return 0
|
||||
@@ -156,16 +183,26 @@ async function forumPost({ team, threadId, threadTitle, type, authorUserId, auth
|
||||
// digest worker has no route in front of it, so the check has to live here as
|
||||
// well as there — and a switch flipped between a write and its notification
|
||||
// must silence the notification.
|
||||
if (!(await forumSettings.forumsEnabled())) return { push: 0, emails: 0 }
|
||||
if (!(await forumSettings.forumsEnabled())) return { push: 0, emails: 0, bridged: false }
|
||||
|
||||
const stream = type === 'announcement' ? STREAMS.ANNOUNCEMENT : STREAMS.FORUM_POST
|
||||
const exclude = authorUserId ? [authorUserId] : []
|
||||
const push = await tickle(stream, team, { ref: `team:${team.id}:thread:${threadId}`, exclude })
|
||||
const emails = await emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml })
|
||||
return { push, emails }
|
||||
// The bridge is NOT given `exclude`. Excluding the author is a property of a
|
||||
// per-recipient sink — nobody wants their own post mailed back to them — and a
|
||||
// channel has no per-recipient anything. Suppressing the message because the
|
||||
// author happens to be in the channel would deprive everyone else in it.
|
||||
const bridged = await teamBridge.deliver(stream, team, {
|
||||
title: threadTitle,
|
||||
body: teamBridge.excerpt(bodyHtml),
|
||||
url: threadUrl(team, threadId),
|
||||
teamUrl: teamPageUrl(team),
|
||||
})
|
||||
return { push, emails, bridged }
|
||||
} catch (err) {
|
||||
log.warn('forum notification failed', { teamId: team && team.id, message: err.message })
|
||||
return { push: 0, emails: 0 }
|
||||
return { push: 0, emails: 0, bridged: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user