interface DiscordEmbed { title?: string; description?: string; color?: number; footer?: { text: string }; } interface DiscordWebhookPayload { content?: string; embeds?: DiscordEmbed[]; } 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; totalPoints: number; rank: number; } export interface ScoredMatch { winnerName: string; loserName: string; winnerUsername?: string; loserUsername?: 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 winnerLabel = match.winnerUsername ? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})` : escapeMarkdown(match.winnerName); const loserLabel = match.loserUsername ? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})` : escapeMarkdown(match.loserName); sections.push(`• **${winnerLabel}** def. ${loserLabel}`); } } // 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 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 escapedUsername = s.username ? escapeMarkdown(s.username) : undefined; const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName; sections.push(`${s.rank}. ${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) + "..."; } await sendDiscordWebhook(webhookUrl, { embeds: [ { title: `📊 Standings Update — ${seasonName}`, description, color: 0x5865f2, // Discord blurple footer: { text: "brackt.com" }, }, ], }); }