Fix Discord pick notifications silently dropped during autodraft chains #57

Merged
chrisp merged 1 commit from fix/discord-pick-notification-queue into main 2026-05-27 16:41:08 +00:00
3 changed files with 94 additions and 53 deletions

View file

@ -10,6 +10,7 @@ import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { sendOnTheClockEmail } from "~/services/draft-email.server"; import { sendOnTheClockEmail } from "~/services/draft-email.server";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server"; import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import { enqueuePickNotification } from "~/services/discord";
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket"; import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
@ -833,21 +834,23 @@ export async function executeAutoPick(params: {
if (!pickedSlot) { if (!pickedSlot) {
logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`); logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`);
} else { } else {
notifyPickMadeOnDiscord({ enqueuePickNotification(season.leagueId, () =>
seasonId, notifyPickMadeOnDiscord({
leagueId: season.leagueId, seasonId,
pickedTeamName: pickedSlot.team.name, leagueId: season.leagueId,
participantName: participantToPick.name, pickedTeamName: pickedSlot.team.name,
sportName: participantToPick.sportsSeason.sport.name, participantName: participantToPick.name,
pickNumber, sportName: participantToPick.sportsSeason.sport.name,
nextPickNumber, pickNumber,
round: currentRound, nextPickNumber,
rawPickInRound, round: currentRound,
isDraftComplete, rawPickInRound,
totalTeams, isDraftComplete,
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), totalTeams,
db, draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
}).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err)); db,
}).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err))
);
} }
// Check if next team has autodraft enabled and trigger immediately // Check if next team has autodraft enabled and trigger immediately

View file

@ -13,6 +13,7 @@ import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import { sendOnTheClockEmail } from "~/services/draft-email.server"; import { sendOnTheClockEmail } from "~/services/draft-email.server";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server"; import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import { enqueuePickNotification } from "~/services/discord";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -305,21 +306,23 @@ export async function action(args: ActionFunctionArgs) {
// Announce before triggering the chain so Discord messages arrive in pick-number // Announce before triggering the chain so Discord messages arrive in pick-number
// order. If the chain ran first, chained picks would announce before this one. // order. If the chain ran first, chained picks would announce before this one.
notifyPickMadeOnDiscord({ enqueuePickNotification(season.leagueId, () =>
seasonId, notifyPickMadeOnDiscord({
leagueId: season.leagueId, seasonId,
pickedTeamName: currentDraftSlot.team.name, leagueId: season.leagueId,
participantName: participant.name, pickedTeamName: currentDraftSlot.team.name,
sportName: participant.sportsSeason.sport.name, participantName: participant.name,
pickNumber: currentPickNumber, sportName: participant.sportsSeason.sport.name,
nextPickNumber, pickNumber: currentPickNumber,
round: currentRound, nextPickNumber,
rawPickInRound, round: currentRound,
isDraftComplete, rawPickInRound,
totalTeams, isDraftComplete,
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), totalTeams,
db, draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
}).catch((err) => logger.error("Discord pick announcement failed:", err)); db,
}).catch((err) => logger.error("Discord pick announcement failed:", err))
);
// Check if next team has autodraft enabled and trigger immediately // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { if (!isDraftComplete) {

View file

@ -14,38 +14,73 @@ interface DiscordWebhookPayload {
allowed_mentions?: { parse: string[]; users: string[] }; allowed_mentions?: { parse: string[]; users: string[] };
} }
// Per-league serial queue: serializes pick notifications within a league to
// respect Discord's per-webhook rate limit (~5 req/2s) during autodraft chains.
// Keyed by leagueId so concurrent drafts in different leagues don't block each other.
const leagueQueues = new Map<string, Array<() => Promise<void>>>();
const drainingLeagues = new Set<string>();
async function drainLeagueQueue(leagueId: string) {
if (drainingLeagues.has(leagueId)) return;
drainingLeagues.add(leagueId);
try {
for (;;) {
const queue = leagueQueues.get(leagueId);
if (!queue || queue.length === 0) break;
const task = queue.shift();
if (!task) break;
try {
await task();
} catch {
// task already .catch()es its own errors; this is a safety net so one
// bad task doesn't prevent the rest of the queue from draining
}
}
} finally {
drainingLeagues.delete(leagueId);
leagueQueues.delete(leagueId);
}
}
export function enqueuePickNotification(leagueId: string, fn: () => Promise<void>): void {
let queue = leagueQueues.get(leagueId);
if (!queue) {
queue = [];
leagueQueues.set(leagueId, queue);
}
queue.push(fn);
drainLeagueQueue(leagueId).catch(() => {});
}
export async function sendDiscordWebhook( export async function sendDiscordWebhook(
webhookUrl: string, webhookUrl: string,
payload: DiscordWebhookPayload payload: DiscordWebhookPayload
): Promise<void> { ): Promise<void> {
const response = await fetch(webhookUrl, { const body = JSON.stringify(payload);
method: "POST", const headers = { "Content-Type": "application/json" };
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (response.status === 429) { for (let attempt = 0; attempt < 3; attempt++) {
// Cap at 10 s so awaited callers (e.g. settings actions) are never hung for const res = await fetch(webhookUrl, { method: "POST", headers, body });
// 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. if (res.status === 429) {
const retryAfterSeconds = Math.min(Number(response.headers.get("Retry-After")) || 1, 10); // Cap the total wait at 10 s so awaited callers (e.g. settings actions) are
await new Promise((r) => setTimeout(r, retryAfterSeconds * 1000)); // never hung for a full Discord global-rate-limit window. Use || 1 instead of
const retry = await fetch(webhookUrl, { // ?? 1 so NaN (non-numeric header) and 0 both fall back to a 1-second default.
method: "POST", // Multiply by (attempt + 1) for progressive backoff, then cap the product.
headers: { "Content-Type": "application/json" }, const wait = Math.min((Number(res.headers.get("Retry-After")) || 1) * (attempt + 1), 10);
body: JSON.stringify(payload), await new Promise((r) => setTimeout(r, wait * 1000));
}); continue;
if (!retry.ok) {
const text = await retry.text().catch(() => "");
throw new Error(`Discord webhook failed after retry: ${retry.status} ${text}`);
} }
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord webhook failed: ${res.status} ${text}`);
}
return; return;
} }
if (!response.ok) { throw new Error("Discord webhook failed after 3 retries (rate limited)");
const text = await response.text().catch(() => "");
throw new Error(`Discord webhook failed: ${response.status} ${text}`);
}
} }
/** Escape characters that trigger Discord markdown formatting. */ /** Escape characters that trigger Discord markdown formatting. */