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,122 @@
// SQL for the integration bridge's configuration (TEAMS.md §7.2, phase 8).
//
// One table, and almost all of its subtlety is in the schema comment rather than
// here: `team_key` is a generated `IFNULL(team_id, 0)`, so the deployment-wide
// default and the per-Team overrides live under one UNIQUE key without the
// default row needing a NULL in a primary key it cannot have.
//
// **Reads join `teams` and callers get the Team's name.** Not for display alone:
// the resolver's answer is the input to a message that names a Team, and a second
// round trip per notification to fetch a name the first query already walked past
// is the kind of thing that only shows up under a busy forum.
const { query } = require('../../utils/db')
const COLUMNS = `
c.id, c.platform, c.team_id, c.events, c.channel_ref, c.enabled,
c.members_ack, c.members_ack_by, c.members_ack_at, c.updated_at`
/**
* Every row for a platform — the default first, then the overrides by Team name.
*
* The admin panel's whole listing, in one query. `team_name` is NULL on exactly
* one row (the default), which is also how the client tells them apart without
* needing to reason about `team_id`.
*/
async function listForPlatform(platform) {
return query(
`SELECT ${COLUMNS}, t.name AS team_name, t.slug AS team_slug, t.display_name_override,
u.username AS members_ack_username
FROM team_integration_config c
LEFT JOIN teams t ON t.id = c.team_id
LEFT JOIN users u ON u.id = c.members_ack_by
WHERE c.platform = ?
ORDER BY c.team_id IS NOT NULL, COALESCE(t.name, '')`,
[platform],
)
}
/**
* The row that governs `teamId`, or null.
*
* `team_key` is what makes this one query rather than two: asking for the pair
* (0, teamId) returns the default and the override together, and `ORDER BY
* team_key DESC LIMIT 1` puts the override first when it exists. A caller that
* fetched the default and then looked for an override would do two round trips
* per notification for an answer the index already holds.
*/
async function resolveFor(platform, teamId) {
const rows = await query(
`SELECT ${COLUMNS}, t.name AS team_name, t.display_name_override
FROM team_integration_config c
LEFT JOIN teams t ON t.id = c.team_id
WHERE c.platform = ? AND c.team_key IN (0, ?)
ORDER BY c.team_key DESC
LIMIT 1`,
[platform, Number(teamId)],
)
return rows[0] || null
}
async function getById(id) {
const rows = await query(
`SELECT ${COLUMNS}, t.name AS team_name FROM team_integration_config c
LEFT JOIN teams t ON t.id = c.team_id
WHERE c.id = ? LIMIT 1`,
[Number(id)],
)
return rows[0] || null
}
async function getForTeam(platform, teamId) {
const rows = await query(
`SELECT ${COLUMNS} FROM team_integration_config c
WHERE c.platform = ? AND c.team_key = ? LIMIT 1`,
[platform, teamId === null || teamId === undefined ? 0 : Number(teamId)],
)
return rows[0] || null
}
/**
* Create or replace the row for (platform, team).
*
* A full replace rather than a patch, and the acknowledgement columns are part of
* what is replaced — the model decides what they should be, because "did the
* channel change" is a comparison against the row that is about to be overwritten
* and only the model has both halves.
*/
async function upsert({ platform, teamId, events, channelRef, enabled, membersAck, membersAckBy, membersAckAt }) {
await query(
`INSERT INTO team_integration_config
(platform, team_id, events, channel_ref, enabled, members_ack, members_ack_by, members_ack_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
events = VALUES(events),
channel_ref = VALUES(channel_ref),
enabled = VALUES(enabled),
members_ack = VALUES(members_ack),
members_ack_by = VALUES(members_ack_by),
members_ack_at = VALUES(members_ack_at)`,
[
platform,
teamId === null || teamId === undefined ? null : Number(teamId),
JSON.stringify(events || []),
channelRef || null,
enabled ? 1 : 0,
membersAck ? 1 : 0,
membersAckBy || null,
membersAckAt || null,
],
)
return getForTeam(platform, teamId)
}
async function remove(platform, teamId) {
const res = await query('DELETE FROM team_integration_config WHERE platform = ? AND team_key = ?', [
platform,
teamId === null || teamId === undefined ? 0 : Number(teamId),
])
return Number(res && res.affectedRows) || 0
}
module.exports = { listForPlatform, resolveFor, getById, getForTeam, upsert, remove }

View File

@@ -0,0 +1,272 @@
// ── The integration bridge's configuration and its one precondition ────────
//
// TEAMS.md §7.2, phase 8. An operator says "send these Team events to this
// Discord channel", globally or for one Team, and this file is where that
// sentence is validated, stored and resolved.
//
// **Two of the four streams can never be public, and that is the whole reason
// this file is more than a settings row.** §7.2 gates bridging on "the event's
// visibility is public, or the destination channel is configured for a
// members-only Team context". Neither half exists in the tree and neither can:
// the four `team.*` streams carry no visibility (only `team_activity` rows do,
// and a notification is not an activity row), forum threads have no public/
// members column because a forum is members-only by construction — everything in
// it sits behind `team_forum_grants` — and core cannot see a Discord channel's
// permissions to know what it is.
//
// Only the operator can see that. So the gate becomes an ATTRIBUTED
// ACKNOWLEDGEMENT: enabling a members-only event requires an explicit tick that
// the destination is restricted to that Team's members, recorded with who gave it
// and when, in the same shape `teams_forum_uploads_ack` records the image-policy
// one. It is a precondition, not a preference — `assertEnableable` refuses the
// save rather than quietly dropping the event at delivery time, because a config
// that silently does less than it says is worse than one that will not save.
//
// **Changing the channel clears the acknowledgement.** An acknowledgement is
// about a destination; it cannot survive the destination changing underneath it,
// or an operator would tick "this channel is private", then repoint the row at a
// public one and keep the permission they were granted for a different place.
//
// **Every read fails closed**, like `teamForumSettings`: a DB fault reports no
// bridge configured, because the cost of failing closed is a Discord channel that
// stays quiet for a minute and the cost of failing open is members-only text in a
// room the operator never approved.
const db = require('./teamIntegration.db')
const log = require('../../utils/logger')('team-integration')
// The only platform phase 8 knows. Deliberately a value rather than a hardcoded
// literal at every call site: phase 10 turns this into a lookup against the
// declared-capability registry, and the fewer places that spell 'discord' the
// smaller that change is.
const DISCORD = 'discord'
const PLATFORMS = [DISCORD]
// The four §6.2 streams, and which of them can reach a channel core cannot vet.
//
// A stream is members-only if the CONTENT behind it is: `team.forum.post` and
// `team.announcement` both name a thread nobody outside the Team may read. The
// roster pair is public — Team pages and rosters are public by §1's projection
// rules — so bridging those asserts nothing and needs no tick.
const BRIDGEABLE = [
'team.member.joined',
'team.leadership.changed',
'team.forum.post',
'team.announcement',
]
const MEMBERS_ONLY = new Set(['team.forum.post', 'team.announcement'])
// Discord snowflakes are 17-20 digits today and the format is not promised. The
// check is only that a channel ref is plausibly one and cannot smuggle anything —
// core treats it as opaque and the bot is what resolves it.
const CHANNEL_RE = /^[0-9]{5,32}$/
const isMembersOnly = (streamId) => MEMBERS_ONLY.has(streamId)
/** Does this event list contain anything that would publish members-only text? */
const needsAck = (events) => (events || []).some(isMembersOnly)
/**
* Normalise an operator-supplied event list.
*
* Unknown ids are REJECTED rather than dropped. A silently-dropped event is a
* config screen that shows you saved something you did not, and the set is small
* and fixed enough that a typo is a mistake worth reporting.
*/
function normaliseEvents(events) {
if (!Array.isArray(events)) {
const err = new Error('events must be an array')
err.status = 400
throw err
}
const seen = []
for (const raw of events) {
const id = String(raw || '').trim()
if (!BRIDGEABLE.includes(id)) {
const err = new Error(`unknown event: ${id}`)
err.status = 400
throw err
}
if (!seen.includes(id)) seen.push(id)
}
return seen
}
function normaliseChannel(channelRef) {
const value = String(channelRef || '').trim()
if (!value) return null
if (!CHANNEL_RE.test(value)) {
const err = new Error('channel must be a numeric channel id')
err.status = 400
throw err
}
return value
}
/**
* The gate, as a throw.
*
* Order matters to the message an operator reads: an enabled row with no channel
* is a different mistake from one with an unacknowledged channel, and reporting
* the second when the first is true would send them to tick a box that would not
* have helped.
*/
function assertEnableable({ enabled, events, channelRef, membersAck }) {
if (!enabled) return
if (!channelRef) {
const err = new Error('a destination channel is required to enable this bridge')
err.status = 422
throw err
}
if (events.length === 0) {
const err = new Error('at least one event is required to enable this bridge')
err.status = 422
throw err
}
if (needsAck(events) && !membersAck) {
const err = new Error(
'forum posts and announcements are visible only to a Teams members — confirm the destination channel is restricted to them before enabling',
)
err.status = 422
err.code = 'members_ack_required'
throw err
}
}
/** Rows for the admin panel, `events` already parsed. */
async function list(platform = DISCORD) {
const rows = await db.listForPlatform(platform)
return rows.map(shape)
}
/**
* Parse the stored JSON once, here.
*
* `mariadb` hands a JSON column back as a string on some server versions and as a
* parsed value on others, which is a difference nobody wants to rediscover in a
* controller. Anything unreadable becomes an empty list rather than a throw: a
* row with a corrupt event list should render as a row that bridges nothing, not
* take the whole admin page down.
*/
function shape(row) {
if (!row) return null
let events = row.events
if (typeof events === 'string') {
try {
events = JSON.parse(events)
} catch {
events = []
}
}
return { ...row, events: Array.isArray(events) ? events : [], enabled: !!row.enabled, members_ack: !!row.members_ack }
}
/**
* The row that governs `teamId` — the override if there is one, otherwise the
* deployment default — filtered down to what may actually be delivered.
*
* **The acknowledgement is checked HERE as well as at the save.** A row saved
* with the tick can lose it later: an admin repoints the channel, or a future
* change to what counts as members-only reclassifies a stream a row already
* carries. Re-asking at delivery is what makes the tick a live property of the
* row rather than a note about a save that happened once.
*/
async function resolve(teamId, platform = DISCORD) {
try {
const row = shape(await db.resolveFor(platform, teamId))
if (!row || !row.enabled || !row.channel_ref) return null
const events = row.events.filter((id) => (isMembersOnly(id) ? row.members_ack : true))
if (events.length === 0) return null
return { ...row, events }
} catch (err) {
log.warn('bridge config lookup failed — treating as unconfigured', {
teamId,
platform,
message: err.message,
})
return null
}
}
/** Is `streamId` bridged for this Team? The delivery path's whole question. */
async function destinationFor(teamId, streamId, platform = DISCORD) {
const row = await resolve(teamId, platform)
if (!row || !row.events.includes(streamId)) return null
return { channelRef: row.channel_ref, membersOnly: isMembersOnly(streamId), platform }
}
/**
* Create or replace the row for (platform, team).
*
* `actorId` is the admin doing the saving, and it is what lands in
* `members_ack_by` — the acknowledgement names a person, so it cannot be written
* by a path that does not know who they are.
*/
async function save({ platform = DISCORD, teamId = null, events, channelRef, enabled, membersAck }, actorId) {
if (!PLATFORMS.includes(platform)) {
const err = new Error(`unknown platform: ${platform}`)
err.status = 400
throw err
}
const nextEvents = normaliseEvents(events)
const nextChannel = normaliseChannel(channelRef)
const existing = shape(await db.getForTeam(platform, teamId))
// An acknowledgement survives an ordinary edit and dies with the channel it was
// given for. `membersAck === false` from the client is an explicit withdrawal
// and is honoured; `undefined` means "leave it", which is what a save that only
// toggled an event should do.
const channelChanged = !!existing && existing.channel_ref !== nextChannel
let ack = existing ? existing.members_ack : false
if (membersAck === false) ack = false
else if (membersAck === true) ack = true
if (channelChanged) ack = membersAck === true
const nextEnabled = !!enabled
assertEnableable({ enabled: nextEnabled, events: nextEvents, channelRef: nextChannel, membersAck: ack })
// Re-stamp only when the acknowledgement is newly given, so an unrelated save
// does not rewrite the date on a decision nobody revisited.
//
// `channelChanged` belongs in this condition and it is easy to leave out: an
// acknowledgement given alongside a NEW channel is a new acknowledgement even
// though the column was already 1, and without it the row keeps naming whoever
// vetted the PREVIOUS destination. That attribution is the whole audit value of
// the column — it has to name the person who looked at the channel the row now
// points at.
const freshlyAcked = ack && (channelChanged || !(existing && existing.members_ack))
const row = await db.upsert({
platform,
teamId,
events: nextEvents,
channelRef: nextChannel,
enabled: nextEnabled,
membersAck: ack,
membersAckBy: ack ? (freshlyAcked ? actorId : existing.members_ack_by) : null,
membersAckAt: ack ? (freshlyAcked ? new Date() : existing.members_ack_at) : null,
})
return shape(row)
}
async function remove(platform, teamId) {
return db.remove(platform, teamId)
}
module.exports = {
DISCORD,
PLATFORMS,
BRIDGEABLE,
MEMBERS_ONLY,
isMembersOnly,
needsAck,
normaliseEvents,
normaliseChannel,
assertEnableable,
list,
resolve,
destinationFor,
save,
remove,
}

View File

@@ -213,7 +213,10 @@ async function logRosterActivity(team, { joined, left, promoted, demoted }) {
async function notifyRoster(team, { joined, promoted, demoted }) {
if (!team.roster_synced_at) return
try {
if (joined.length > 0) await teamNotify.memberJoined(team)
// The count rides along for the Discord bridge (§7.2), which has no app on
// the other end to pull the roster after a content-free nudge. The tickle
// itself is unchanged and still carries nothing.
if (joined.length > 0) await teamNotify.memberJoined(team, { count: joined.length })
if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team)
} catch (err) {
log.warn('roster notification not sent', { teamId: team.id, message: err.message })

View File

@@ -16,6 +16,7 @@ const forum = require('../../../model/teams/teamForum.model')
const forumDb = require('../../../model/teams/teamForum.db')
const forumUploadsModel = require('../../../model/teams/teamForumUploads.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const integration = require('../../../model/teams/teamIntegration.model')
const log = require('../../../utils/logger')('teams')
@@ -265,7 +266,91 @@ async function decideRequest(req, res) {
}
}
// ── The integration bridge (§7.2, phase 8) — admin only ───────────────────
//
// Admin-only at the ROUTER, unlike everything above it. The §2.9 gate exists
// because a moderator's action publishes untrusted game strings to the public
// site; this is a different risk in the other direction — it decides that
// members-only forum text leaves the site altogether, for a destination core
// cannot see. That is a deployment-configuration decision, and it sits with the
// role that holds the bot token rather than with the queue.
async function integrationConfig(req, res) {
try {
return res.json({
platform: integration.DISCORD,
events: integration.BRIDGEABLE.map((id) => ({ id, membersOnly: integration.isMembersOnly(id) })),
rows: await integration.list(integration.DISCORD),
})
} catch (err) {
return fail(res, err, 'integration config')
}
}
async function saveIntegrationConfig(req, res) {
try {
// `teamId` null is the deployment default and is a legitimate body, so the
// absent-vs-null distinction matters: a PUT with no teamId edits the default.
const teamId = req.body.teamId === undefined || req.body.teamId === null ? null : Number(req.body.teamId)
if (teamId !== null && !(await teamsDb.findById(teamId))) {
return res.status(404).json({ message: 'Team not found' })
}
const row = await integration.save(
{
platform: integration.DISCORD,
teamId,
events: req.body.events,
channelRef: req.body.channelRef,
enabled: req.body.enabled,
membersAck: req.body.membersAck,
},
req.user.id,
)
await activity.log({
req,
action: 'team.integration.save',
detail:
`${req.user.username} (#${req.user.id}) saved the ${integration.DISCORD} bridge for ` +
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}: ` +
`${row.enabled ? 'enabled' : 'disabled'}, events [${row.events.join(', ')}]` +
`${row.members_ack ? ', members-only destination acknowledged' : ''}`,
})
return res.json(row)
} catch (err) {
// A validation refusal carries its own status and its own wording — the
// acknowledgement message in particular is the whole explanation of why the
// save was refused, and collapsing it into a 500 would leave the operator
// with a screen that will not save and no reason given.
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
return fail(res, err, 'save integration config')
}
}
async function deleteIntegrationConfig(req, res) {
try {
const teamId = req.params.teamId === 'default' ? null : Number(req.params.teamId)
const removed = await integration.remove(integration.DISCORD, teamId)
if (removed === 0) return res.status(404).json({ message: 'No configuration for that Team' })
await activity.log({
req,
action: 'team.integration.delete',
detail:
`${req.user.username} (#${req.user.id}) removed the ${integration.DISCORD} bridge for ` +
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'delete integration config')
}
}
module.exports = {
integrationConfig,
saveIntegrationConfig,
deleteIntegrationConfig,
forumModeration,
forumUploads,
forumSettingsState,

View File

@@ -19,9 +19,16 @@ const { body, param, query } = require('express-validator')
const ctrl = require('./teams.controller')
const validate = require('../../../middleware/validate')
const { requireRole } = require('../../../utils/auth')
const teamsRouter = express.Router()
// The one ADMIN-only corner of a staff-wide router (§7.2, phase 8). Configuring
// where a Team's events leave the site for is not the §2.9 kind of decision a
// moderator files a request for; it is deployment configuration, and it sits with
// the role that already holds the bot token.
const adminOnly = requireRole('admin')
// ── Literal paths, first ───────────────────────────────────────────────────
teamsRouter.get(
@@ -123,6 +130,54 @@ teamsRouter.get(
ctrl.forumSettingsState,
)
// ── The integration bridge (§7.2) — literal, and before /:id ──────────────
teamsRouter.get(
'/integrations',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The Team notification bridges configuration (admin only)'
// #swagger.description = 'Every configured destination for the platform, the deployment-wide default first, alongside the events that may be bridged and which of them are members-only. A members-only event carries content nobody outside the Team may read, so enabling one requires an acknowledgement that the destination channel is restricted to that Teams members — recorded here with who gave it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Bridge configuration', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.integrationConfig,
)
teamsRouter.put(
'/integrations',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Create or replace one bridge destination (admin only)'
// #swagger.description = 'Omit teamId (or send null) to edit the deployment-wide default; a per-Team row overrides it. Enabling a bridge that carries team.forum.post or team.announcement without membersAck is refused 422 — the events are members-only always, and core cannot see a Discord channels permissions, so the operators acknowledgement is the only thing that can stand in for the check. Changing the channel clears a previous acknowledgement: it was given for a destination, not for a row.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The saved row', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationRow" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[422] = { description: 'Not enableable — no channel, no events, or a members-only event without the acknowledgement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('teamId').optional({ nullable: true }).isInt({ min: 1 }).toInt(),
body('events').isArray({ max: 8 }),
body('channelRef').optional({ nullable: true }).isString().trim().isLength({ max: 64 }),
body('enabled').optional().isBoolean().toBoolean(),
body('membersAck').optional().isBoolean().toBoolean(),
validate,
ctrl.saveIntegrationConfig,
)
teamsRouter.delete(
'/integrations/:teamId',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Remove one bridge destination (admin only)'
// #swagger.description = 'Pass the literal string default to remove the deployment-wide row. Removing a per-Team override makes that Team fall back to the default, which is not the same as disabling it — disable the row instead if that is what is wanted.'
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Team id, or the literal string default.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('teamId').custom((v) => v === 'default' || /^[0-9]+$/.test(v)),
validate,
ctrl.deleteIntegrationConfig,
)
teamsRouter.get(
'/:id',
// #swagger.tags = ['Admin · Teams']

View File

@@ -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 }

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 }

View File

@@ -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 }
}
}