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 { 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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue