Fix Discord pick notifications silently dropped during autodraft chains #57
3 changed files with 94 additions and 53 deletions
|
|
@ -10,6 +10,7 @@ import { getSeasonSportsSimple } from "./season-sport";
|
|||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { sendOnTheClockEmail } from "~/services/draft-email.server";
|
||||
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
|
||||
import { enqueuePickNotification } from "~/services/discord";
|
||||
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
|
||||
|
|
@ -833,21 +834,23 @@ export async function executeAutoPick(params: {
|
|||
if (!pickedSlot) {
|
||||
logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`);
|
||||
} else {
|
||||
notifyPickMadeOnDiscord({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
pickedTeamName: pickedSlot.team.name,
|
||||
participantName: participantToPick.name,
|
||||
sportName: participantToPick.sportsSeason.sport.name,
|
||||
pickNumber,
|
||||
nextPickNumber,
|
||||
round: currentRound,
|
||||
rawPickInRound,
|
||||
isDraftComplete,
|
||||
totalTeams,
|
||||
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
|
||||
db,
|
||||
}).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err));
|
||||
enqueuePickNotification(season.leagueId, () =>
|
||||
notifyPickMadeOnDiscord({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
pickedTeamName: pickedSlot.team.name,
|
||||
participantName: participantToPick.name,
|
||||
sportName: participantToPick.sportsSeason.sport.name,
|
||||
pickNumber,
|
||||
nextPickNumber,
|
||||
round: currentRound,
|
||||
rawPickInRound,
|
||||
isDraftComplete,
|
||||
totalTeams,
|
||||
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
|
||||
db,
|
||||
}).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err))
|
||||
);
|
||||
}
|
||||
|
||||
// Check if next team has autodraft enabled and trigger immediately
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { logger } from "~/lib/logger";
|
|||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
import { sendOnTheClockEmail } from "~/services/draft-email.server";
|
||||
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
|
||||
import { enqueuePickNotification } from "~/services/discord";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
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
|
||||
// order. If the chain ran first, chained picks would announce before this one.
|
||||
notifyPickMadeOnDiscord({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
pickedTeamName: currentDraftSlot.team.name,
|
||||
participantName: participant.name,
|
||||
sportName: participant.sportsSeason.sport.name,
|
||||
pickNumber: currentPickNumber,
|
||||
nextPickNumber,
|
||||
round: currentRound,
|
||||
rawPickInRound,
|
||||
isDraftComplete,
|
||||
totalTeams,
|
||||
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
|
||||
db,
|
||||
}).catch((err) => logger.error("Discord pick announcement failed:", err));
|
||||
enqueuePickNotification(season.leagueId, () =>
|
||||
notifyPickMadeOnDiscord({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
pickedTeamName: currentDraftSlot.team.name,
|
||||
participantName: participant.name,
|
||||
sportName: participant.sportsSeason.sport.name,
|
||||
pickNumber: currentPickNumber,
|
||||
nextPickNumber,
|
||||
round: currentRound,
|
||||
rawPickInRound,
|
||||
isDraftComplete,
|
||||
totalTeams,
|
||||
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
|
||||
db,
|
||||
}).catch((err) => logger.error("Discord pick announcement failed:", err))
|
||||
);
|
||||
|
||||
// Check if next team has autodraft enabled and trigger immediately
|
||||
if (!isDraftComplete) {
|
||||
|
|
|
|||
|
|
@ -14,38 +14,73 @@ interface DiscordWebhookPayload {
|
|||
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(
|
||||
webhookUrl: string,
|
||||
payload: DiscordWebhookPayload
|
||||
): Promise<void> {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = JSON.stringify(payload);
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
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}`);
|
||||
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;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Discord webhook failed: ${response.status} ${text}`);
|
||||
}
|
||||
throw new Error("Discord webhook failed after 3 retries (rate limited)");
|
||||
}
|
||||
|
||||
/** Escape characters that trigger Discord markdown formatting. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue