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[] }; } // Per-league serial promise chain: serializes pick notifications within a league to // respect Discord's per-webhook rate limit (~5 req/2s) during autodraft chains. const leagueChains = new Map>(); export function enqueuePickNotification(leagueId: string, fn: () => Promise): void { const prev = leagueChains.get(leagueId) ?? Promise.resolve(); const next = prev.then(() => fn().catch(() => {})); leagueChains.set(leagueId, next); // Clean up the map entry once the chain settles, but only if no newer pick // was enqueued after this one (reference equality guards against that race). next.then(() => { if (leagueChains.get(leagueId) === next) leagueChains.delete(leagueId); }); } export async function sendDiscordWebhook( webhookUrl: string, payload: DiscordWebhookPayload ): Promise { const body = JSON.stringify(payload); const headers = { "Content-Type": "application/json" }; for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch(webhookUrl, { method: "POST", headers, body }); if (res.status === 429) { // Cap the total wait at 10 s so awaited callers (e.g. settings actions) are // never hung for a full Discord global-rate-limit window. Use || 1 instead of // ?? 1 so NaN (non-numeric header) and 0 both fall back to a 1-second default. // Multiply by (attempt + 1) for progressive backoff, then cap the product. const wait = Math.min((Number(res.headers.get("Retry-After")) || 1) * (attempt + 1), 10); await new Promise((r) => setTimeout(r, wait * 1000)); continue; } if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Discord webhook failed: ${res.status} ${text}`); } return; } throw new Error("Discord webhook failed after 3 retries (rate limited)"); } /** Escape characters that trigger Discord markdown formatting. */ function escapeMarkdown(text: string): string { return text.replace(/[_*~`|\\]/g, "\\$&"); } /** * Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser * earns 1.5 QP from the 9–16 split), so we must NOT round: show whole numbers plainly * and fractional values to 2 decimals. Mirrors the web UI's formatQP * (app/components/scoring/QualifyingPointsStandings.tsx) so Discord and the site agree. */ function formatQPValue(n: number): string { return n % 1 === 0 ? n.toString() : n.toFixed(2); } 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 interface EliminatedTeam { participantName: string; username?: string; discordUserId?: string; } export async function sendStandingsUpdateNotification({ webhookUrl, seasonName, standings, previousStandings, previousRanks, sportName, eventName, scoredMatches, eliminatedTeams, }: { webhookUrl: string; seasonName: string; standings: StandingEntry[]; previousStandings: Map; previousRanks?: Map; sportName?: string; eventName?: string; scoredMatches?: ScoredMatch[]; eliminatedTeams?: EliminatedTeam[]; }): 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}`); } } // Eliminated teams section — drafted teams knocked out when a bracket/knockout // was generated (e.g. NBA teams that missed the playoffs, FIFA group-stage // losers). These produce no score change, so they wouldn't appear above. if (eliminatedTeams && eliminatedTeams.length > 0) { sections.push("\n**Eliminated**"); for (const team of eliminatedTeams) { const managerLabel = team.discordUserId ? `<@${team.discordUserId}>` : team.username ? escapeMarkdown(team.username) : undefined; const label = managerLabel ? `${escapeMarkdown(team.participantName)} (${managerLabel})` : escapeMarkdown(team.participantName); sections.push(`• **${label}**`); } } const isTied = buildTiedRankChecker(standings.map((s) => s.rank)); const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`); // A team's points genuinely changed this event (vs. merely being displaced in // rank because someone else scored). Only real scorers are pinged; rank-only // shufflers are still displayed, but by name and without an @-mention. const pointsChanged = (s: StandingEntry) => { const prevPoints = previousStandings.get(s.teamId); return prevPoints !== undefined && prevPoints !== s.totalPoints; }; // Standings changes section — show teams whose points or rank changed. const changedTeams = standings.filter((s) => { const prevRank = previousRanks?.get(s.teamId); const rankChanged = prevRank !== undefined && prevRank !== s.rank; return pointsChanged(s) || 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 && pointsChanged(s) ? `<@${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 && pointsChanged(s)) pingUserIds.add(s.discordUserId); } for (const m of relevantMatches ?? []) { if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId); if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId); } for (const t of eliminatedTeams ?? []) { if (t.discordUserId) pingUserIds.add(t.discordUserId); } const pingIds = [...pingUserIds]; const payload: DiscordWebhookPayload = { embeds: [ { title: `📊 Standings Update — ${seasonName}`, description, color: 0xffd700, // Gold 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 interface QPEventEntry { participantName: string; qpEarned: number; qpTotal: number; /** * The participant's rank in the FULL season QP standings (all participants), not * their position among this event's scorers. Computed by the caller so the "QP * Standings" block reflects the whole sport season — e.g. two R16 losers on 1.5 QP * show as T9 (8 players ahead) rather than T1 among just the two of them. */ globalRank: number; /** True when another participant in the full field shares this globalRank. */ globalRankTied: boolean; ownerUsername?: string; ownerDiscordUserId?: string; } /** A drafted player knocked out this sync in a non-scoring round (0 QP). */ export interface QPEliminatedEntry { participantName: string; ownerUsername?: string; ownerDiscordUserId?: string; } export async function sendQualifyingPointsUpdateNotification({ webhookUrl, seasonName, sportName, eventName, entries, eliminated = [], }: { webhookUrl: string; seasonName: string; sportName?: string; eventName?: string; entries: QPEventEntry[]; eliminated?: QPEliminatedEntry[]; }): Promise { if (entries.length === 0 && eliminated.length === 0) return; const sections: string[] = []; if (sportName || eventName) { const parts = [sportName, eventName].filter(Boolean); sections.push(`**${parts.join(" — ")}**`); } const awardedEntries = entries .filter((e) => e.qpEarned > 0) .toSorted((a, b) => b.qpEarned - a.qpEarned); if (awardedEntries.length > 0) { sections.push("\n**Points Awarded**"); for (const e of awardedEntries) { const managerLabel = e.ownerDiscordUserId ? `<@${e.ownerDiscordUserId}>` : e.ownerUsername ? escapeMarkdown(e.ownerUsername) : undefined; const label = managerLabel ? `${escapeMarkdown(e.participantName)} (${managerLabel})` : escapeMarkdown(e.participantName); sections.push(`• **${label}** — +${formatQPValue(e.qpEarned)} QP`); } } const zeroEntries = entries.filter((e) => e.qpEarned === 0); if (zeroEntries.length > 0) { sections.push("\n**No Points**"); for (const e of zeroEntries) { const managerLabel = e.ownerDiscordUserId ? `<@${e.ownerDiscordUserId}>` : e.ownerUsername ? escapeMarkdown(e.ownerUsername) : undefined; const label = managerLabel ? `${escapeMarkdown(e.participantName)} (${managerLabel})` : escapeMarkdown(e.participantName); sections.push(`• ${label}`); } } // Knocked-out section — drafted players eliminated this sync in a non-scoring // round. They earn no QP, so they'd otherwise never be surfaced to their manager. if (eliminated.length > 0) { sections.push("\n**Knocked Out**"); for (const e of eliminated) { const managerLabel = e.ownerDiscordUserId ? `<@${e.ownerDiscordUserId}>` : e.ownerUsername ? escapeMarkdown(e.ownerUsername) : undefined; const label = managerLabel ? `${escapeMarkdown(e.participantName)} (${managerLabel})` : escapeMarkdown(e.participantName); sections.push(`• ${label}`); } } // Order by the participant's rank in the FULL season standings (globalRank), // supplied by the caller, so the block reads as a season leaderboard slice. const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank); // The standings block reflects QP earners; skip it entirely when this sync only // reported knockouts (no QP change) so we don't emit an empty header. if (sorted.length > 0) { sections.push("\n**QP Standings**"); for (const e of sorted) { const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`; const ownerLabel = e.ownerDiscordUserId ? `<@${e.ownerDiscordUserId}>` : e.ownerUsername ? escapeMarkdown(e.ownerUsername) : undefined; const managerLabel = ownerLabel ? ` (${ownerLabel})` : ""; sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel} — ${formatQPValue(e.qpTotal)} QP`); } } const MAX_DESCRIPTION = 4096; let description = sections.join("\n"); if (description.length > MAX_DESCRIPTION) { description = description.slice(0, MAX_DESCRIPTION - 3) + "..."; } const pingUserIds = new Set(); for (const e of [...awardedEntries, ...zeroEntries, ...eliminated]) { if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId); } const pingIds = [...pingUserIds]; const payload: DiscordWebhookPayload = { embeds: [ { title: `🏅 Qualifying Points Update — ${seasonName}`, description, color: 0x5865f2, // Discord blurple footer: { text: "brackt.com" }, }, ], }; if (pingIds.length > 0) { 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 sendPickAnnouncementNotification({ webhookUrl, draftUrl, pickNumber, round, pickInRound, pickedTeamName, participantName, sportName, nextTeamName, nextOwnerDiscordId, isDraftComplete, }: { webhookUrl: string; draftUrl: string; pickNumber: number; round: number; pickInRound: number; pickedTeamName: string; participantName: string; sportName: string; nextTeamName?: string; nextOwnerDiscordId?: string; isDraftComplete: boolean; }): Promise { const lines: string[] = [ `**${escapeMarkdown(pickedTeamName)}** selected **${escapeMarkdown(participantName)}** (${escapeMarkdown(sportName)})`, ]; if (isDraftComplete) { lines.push("", "The draft is complete!"); } else if (nextTeamName) { const nextLabel = nextOwnerDiscordId ? `<@${nextOwnerDiscordId}>` : escapeMarkdown(nextTeamName); lines.push("", `On the clock: **${escapeMarkdown(nextTeamName)}** (${nextLabel})`); } const description = lines.join("\n"); const payload: DiscordWebhookPayload = { embeds: [ { title: `Pick #${pickNumber} — Round ${round}, Pick ${pickInRound}`, url: draftUrl, description, color: 0x5865f2, }, ], }; if (nextOwnerDiscordId && !isDraftComplete) { payload.content = `<@${nextOwnerDiscordId}>`; payload.allowed_mentions = { parse: [], users: [nextOwnerDiscordId] }; } 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, }, ], }); }