import { buildTiedRankChecker } from "~/lib/standings-display"; interface DiscordEmbed { title?: string; url?: string; description?: string; color?: number; footer?: { text: string }; } interface DiscordWebhookPayload { content?: string; embeds?: DiscordEmbed[]; allowed_mentions?: { parse: string[]; users: string[] }; } export async function sendDiscordWebhook( webhookUrl: string, payload: DiscordWebhookPayload ): Promise { const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(`Discord webhook failed: ${response.status} ${text}`); } } /** Escape characters that trigger Discord markdown formatting. */ function escapeMarkdown(text: string): string { return text.replace(/[_*~`|\\]/g, "\\$&"); } export interface StandingEntry { teamId: string; teamName: string; username?: string; discordUserId?: string; totalPoints: number; rank: number; } export interface ScoredMatch { winnerName: string; loserName: string; winnerUsername?: string; loserUsername?: string; winnerDiscordUserId?: string; loserDiscordUserId?: string; } export async function sendStandingsUpdateNotification({ webhookUrl, seasonName, standings, previousStandings, previousRanks, sportName, eventName, scoredMatches, }: { webhookUrl: string; seasonName: string; standings: StandingEntry[]; previousStandings: Map; previousRanks?: Map; sportName?: string; eventName?: string; scoredMatches?: ScoredMatch[]; }): Promise { const sections: string[] = []; // Header: "Sport Name — Event Name" if (sportName || eventName) { const parts = [sportName, eventName].filter(Boolean); sections.push(`**${parts.join(" — ")}**`); } // Scored matches section — only show matches where at least one manager // (fantasy team owner) scored Brackt points or had a team eliminated. const relevantMatches = scoredMatches?.filter( (m) => m.winnerUsername !== undefined || m.loserUsername !== undefined ); if (relevantMatches && relevantMatches.length > 0) { sections.push("\n**Scored Matches**"); for (const match of relevantMatches) { const winnerManagerLabel = match.winnerDiscordUserId ? `<@${match.winnerDiscordUserId}>` : match.winnerUsername ? escapeMarkdown(match.winnerUsername) : undefined; const loserManagerLabel = match.loserDiscordUserId ? `<@${match.loserDiscordUserId}>` : match.loserUsername ? escapeMarkdown(match.loserUsername) : undefined; const winnerLabel = winnerManagerLabel ? `${escapeMarkdown(match.winnerName)} (${winnerManagerLabel})` : escapeMarkdown(match.winnerName); const loserLabel = loserManagerLabel ? `${escapeMarkdown(match.loserName)} (${loserManagerLabel})` : escapeMarkdown(match.loserName); sections.push(`• **${winnerLabel}** def. ${loserLabel}`); } } const isTied = buildTiedRankChecker(standings.map((s) => s.rank)); const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`); // Standings changes section — show teams whose points or rank changed. const changedTeams = standings.filter((s) => { const prevPoints = previousStandings.get(s.teamId); const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints; const prevRank = previousRanks?.get(s.teamId); const rankChanged = prevRank !== undefined && prevRank !== s.rank; return pointsChanged || rankChanged; }); if (changedTeams.length > 0) { sections.push("\n**Standings Changes**"); for (const s of changedTeams) { const rankPrefix = rankLabel(s.rank); const prevPoints = previousStandings.get(s.teamId); let pointDelta = ""; if (prevPoints !== undefined && prevPoints !== s.totalPoints) { const diff = Math.round(s.totalPoints - prevPoints); const sign = diff > 0 ? "+" : ""; pointDelta = ` **(${sign}${diff} pts)**`; } let rankDelta = ""; if (previousRanks) { const prevRank = previousRanks.get(s.teamId); if (prevRank !== undefined && prevRank !== s.rank) { const moved = prevRank - s.rank; // positive = moved up rankDelta = moved > 0 ? ` ↑${moved}` : ` ↓${Math.abs(moved)}`; } } const escapedName = escapeMarkdown(s.teamName); const managerLabel = s.discordUserId ? `<@${s.discordUserId}>` : s.username ? escapeMarkdown(s.username) : undefined; const label = managerLabel ? `${escapedName} (${managerLabel})` : escapedName; sections.push(`${rankPrefix}\\. ${label} — ${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`); } } const MAX_DESCRIPTION = 4096; let description = sections.join("\n"); if (description.length > MAX_DESCRIPTION) { description = description.slice(0, MAX_DESCRIPTION - 3) + "..."; } // Collect Discord user IDs of all opted-in managers appearing in this notification. const pingUserIds = new Set(); for (const s of changedTeams) { if (s.discordUserId) pingUserIds.add(s.discordUserId); } for (const m of relevantMatches ?? []) { if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId); if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId); } const pingIds = [...pingUserIds]; const payload: DiscordWebhookPayload = { embeds: [ { title: `📊 Standings Update — ${seasonName}`, description, color: 0x5865f2, // Discord blurple footer: { text: "brackt.com" }, }, ], }; if (pingIds.length > 0) { // Discord caps allowed_mentions.users at 100; slice to avoid a rejected payload. const cappedIds = pingIds.slice(0, 100); payload.content = cappedIds.map((id) => `<@${id}>`).join(" "); payload.allowed_mentions = { parse: [], users: cappedIds }; } await sendDiscordWebhook(webhookUrl, payload); } export async function sendDraftOrderNotification({ webhookUrl, leagueName, leagueUrl, method, teams, }: { webhookUrl: string; leagueName: string; leagueUrl: string; method: "manual" | "randomized"; teams: Array<{ name: string; position: number; username?: string }>; }): Promise { const title = method === "manual" ? `Draft Order Manually Set — ${leagueName}` : `Draft Order Randomized — ${leagueName}`; const sorted = teams.toSorted((a, b) => a.position - b.position); let description = sorted .map((t) => { const teamLabel = escapeMarkdown(t.name); const usernameLabel = t.username ? ` (${escapeMarkdown(t.username)})` : ""; return `${t.position}. ${teamLabel}${usernameLabel}`; }) .join("\n"); if (description.length > 4096) { description = description.slice(0, 4093) + "..."; } await sendDiscordWebhook(webhookUrl, { embeds: [ { title, url: leagueUrl, description, color: 0x5865f2, }, ], }); }