All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 1m30s
🚀 Deploy / ʦ TypeScript (pull_request) Successful in 1m16s
🚀 Deploy / 🔍 Lint (pull_request) Successful in 48s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- Move notifyPickMadeOnDiscord() before checkAndTriggerNextAutodraft() in both executeAutoPick() and the manual pick action. Previously the triggering pick's announcement fired after all chained autodraft picks had already announced, causing Discord to show e.g. pick #106 before #105 before #104. Now each pick announces itself before the next pick in the chain is triggered. - Add a single retry with Retry-After delay in sendDiscordWebhook() on HTTP 429. A burst of consecutive autodraft picks could hit Discord's per-webhook rate limit and silently drop announcements. Use || 1 (not ?? 1) to handle non-numeric/NaN headers, and cap at 10 s so that awaited callers such as the draft-order settings actions are never hung for a full global rate-limit window. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
309 lines
9.3 KiB
TypeScript
309 lines
9.3 KiB
TypeScript
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<void> {
|
|
const response = await fetch(webhookUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (response.status === 429) {
|
|
// Cap 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 that
|
|
// NaN (non-numeric header) and 0 both fall back to a 1-second default.
|
|
const retryAfterSeconds = Math.min(Number(response.headers.get("Retry-After")) || 1, 10);
|
|
await new Promise((r) => setTimeout(r, retryAfterSeconds * 1000));
|
|
const retry = await fetch(webhookUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!retry.ok) {
|
|
const text = await retry.text().catch(() => "");
|
|
throw new Error(`Discord webhook failed after retry: ${retry.status} ${text}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
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<string, number>;
|
|
previousRanks?: Map<string, number>;
|
|
sportName?: string;
|
|
eventName?: string;
|
|
scoredMatches?: ScoredMatch[];
|
|
}): Promise<void> {
|
|
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<string>();
|
|
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 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<void> {
|
|
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<void> {
|
|
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,
|
|
},
|
|
],
|
|
});
|
|
}
|