// Team notifications posted into an operator-configured channel (TEAMS.md §7.2). // // **The channel comes from the app, not from guild_config.** `newsAnnounce` looks // its channel up here because there is exactly one #news; a Team's destination is // per-Team configuration living in `team_integration_config`, and a bot that // resolved it would need a second copy of that table and a second place for it to // drift. The app sends the id it already decided on. // // **Everything this file knows about a Team it was told.** No lookups, no // membership checks, no access decisions: whether this content may reach this // channel was settled on the site, where the acknowledgement that gates it lives. // The bot is the transport, exactly as it is for slash commands. const { EmbedBuilder } = require('discord.js') const brand = require('../brand') const createLogger = require('../utils/logger') const log = createLogger('team-notify') // Discord's own limits. Truncating here rather than trusting the app is not // distrust — an embed that exceeds them is rejected wholesale, and a message // silently not appearing is the worst failure mode this path has. const TITLE_MAX = 256 const DESCRIPTION_MAX = 4096 const clamp = (value, max) => { const text = String(value || '').trim() if (!text) return null return text.length > max ? `${text.slice(0, max - 1)}…` : text } // What each stream is called in a channel. The app composes the BODY; this is // only the label above it, and it is here because it is Discord presentation — // the same reason the embed colour is. const HEADINGS = { 'team.member.joined': 'New member', 'team.leadership.changed': 'Leadership change', 'team.forum.post': 'New forum post', 'team.announcement': 'Announcement', } async function postTeamNotification(client, { channelId, stream, teamName, teamUrl, title, body, url }) { if (!channelId) throw new Error('No channel id supplied.') const channel = await client.channels.fetch(channelId).catch(() => null) if (!channel || !channel.isTextBased()) { throw new Error('Configured channel is missing, not text-based, or not visible to the bot.') } const heading = HEADINGS[stream] || 'Team update' const name = clamp(teamName, 120) || 'A team' const embed = new EmbedBuilder() .setColor(brand.accentInt) // The Team is the AUTHOR line and the event is the title, not the other way // round: a channel carrying one Team's events would otherwise repeat its name // as every heading, and a channel carrying several needs the name to be the // thing the eye lands on first. .setAuthor(teamUrl ? { name, url: teamUrl } : { name }) .setTitle(clamp(title, TITLE_MAX) || heading) if (url) embed.setURL(url) // Both a title and a body means a forum post: the heading has to go somewhere // or "New forum post" and "Announcement" become indistinguishable once the // thread title takes the title slot. // // **Clamped AFTER the heading is prepended, not before.** Clamping the body and // then adding a prefix produces a description one heading longer than the limit, // which discord.js rejects outright — so an over-long post would not arrive at // all rather than arriving truncated. The prefix is part of what has to fit. const composed = title && body ? `**${heading}**\n${String(body)}` : body const description = clamp(composed, DESCRIPTION_MAX) if (description) embed.setDescription(description) await channel.send({ embeds: [embed] }) log.info('team notification posted', { stream, channelId, team: name }) } module.exports = { postTeamNotification, HEADINGS, clamp, TITLE_MAX, DESCRIPTION_MAX }