import { createHash, randomUUID } from "node:crypto"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "../shared/database"; import { DiscordClient, DiscordDeliveryError, discordConfiguration, discordText, type DiscordMessage, } from "./discord-client"; export const TOURNAMENT_NEWS_CHANNEL = "1558048830770122772"; export const TOURNAMENT_EVENT_ROLES = [ "1558053709554909205", "1558052962817933322", ] as const; export const TOURNAMENT_ANNOUNCEMENT_LEAD_MS = 15 * 60 * 1000; export type TournamentAnnouncementSource = { id: string; name: string; owner: string; starts_at: string; server_timezone: string; player_limit: number; round_count: number; round_time_seconds: number; timer_mode: "shared" | "perMini"; status: string; announce_discord: boolean; }; export type TournamentAnnouncementJob = { id: string; tournament_id: string; state: "pending" | "sending" | "sent" | "skipped" | "failed"; attempts: number; next_attempt_at: string; lease_owner: string | null; lease_until: string | null; channel_id: string | null; message_id: string | null; sent_at: string | null; last_error: string | null; }; export function tournamentAnnouncementMessage( event: string, source: TournamentAnnouncementSource, ): DiscordMessage { if (!/^[a-zA-Z0-9-]{1,80}$/.test(source.id)) throw new Error("Invalid tournament link."); const timestamp = Date.parse(source.starts_at); if (!Number.isFinite(timestamp)) throw new Error("Invalid tournament start time."); const serverTime = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", timeZone: source.server_timezone, }).format(timestamp); const link = `https://minegeo.site/tournaments/${source.id}`; return { content: `${TOURNAMENT_EVENT_ROLES.map((role) => `<@&${role}>`).join(", ")}.\n\n## ${discordText(source.name, 120)} will starting soon!\n\nAccess it by this link [${link}](${link})\n\n-------`, allowed_mentions: { roles: [...TOURNAMENT_EVENT_ROLES], users: [], replied_user: false, }, nonce: createHash("sha256") .update(`tournament:${event}`) .digest("hex") .slice(0, 24), enforce_nonce: true, embeds: [ { title: "A community expedition is about to begin!", url: link, description: "Join the lobby before the scheduled start. Play the same locations, climb the final standings, and compete for the champion spotlight on the global leaderboard for seven days.", color: 0x55d6ad, thumbnail: { url: "https://minegeo.site/images/minegeo-mark.webp" }, fields: [ { name: "Organizer", value: discordText(source.owner, 80), inline: true, }, { name: "Players", value: `Up to ${source.player_limit}`, inline: true, }, { name: "Format", value: `${source.round_count} tournament round${source.round_count === 1 ? "" : "s"} · 5 mini-rounds each`, inline: false, }, { name: "Round timer", value: source.timer_mode === "shared" ? `${source.round_time_seconds} seconds shared across all 5 mini-rounds` : `${source.round_time_seconds} seconds per mini-round`, inline: false, }, { name: "Starts · server time", value: `${serverTime} (${discordText(source.server_timezone, 80)})`, inline: false, }, { name: "Starts · your local time", value: ` · `, inline: false, }, ], footer: { text: "MineGeo · Community tournaments" }, }, ], }; } export async function deliverTournamentAnnouncement( job: TournamentAnnouncementJob, dependencies: { client: Pick; load: () => Promise; finish: (patch: Partial) => Promise; now?: () => number; }, ) { const { client, load, finish, now = Date.now } = dependencies; const released = { lease_owner: null, lease_until: null }; try { const source = await load(); if ( !source || source.id !== job.tournament_id || !source.announce_discord || source.status !== "scheduled" || Date.parse(source.starts_at) <= now() ) { await finish({ ...released, state: "skipped", last_error: null }); return; } const due = Date.parse(source.starts_at) - TOURNAMENT_ANNOUNCEMENT_LEAD_MS; // Rescheduling must never produce an early announcement, even with an old lease. if (due > now()) { await finish({ ...released, state: "pending", next_attempt_at: new Date(due).toISOString(), last_error: null, }); return; } const message = await client.send( TOURNAMENT_NEWS_CHANNEL, tournamentAnnouncementMessage(job.id, source), ); await finish({ ...released, state: "sent", channel_id: TOURNAMENT_NEWS_CHANNEL, message_id: message, sent_at: new Date(now()).toISOString(), last_error: null, }); } catch (failure) { const error = failure instanceof DiscordDeliveryError ? failure : new DiscordDeliveryError( "Tournament announcement could not be confirmed. Delivery will retry.", ); await finish({ ...released, state: error.permanent || job.attempts >= 8 ? "failed" : "pending", last_error: error.message, next_attempt_at: new Date( now() + Math.max( error.retryAfter, Math.min(3600, 30 * 2 ** (job.attempts - 1)), ) * 1000, ).toISOString(), }); } } export async function dispatchTournamentAnnouncements( db: SupabaseClient, env: Record = process.env, target?: string, ) { const config = discordConfiguration(env); if (!config.token) return; const worker = randomUUID(); const claimed = await db .rpc("claim_tournament_announcements", { p_worker: worker, p_target: target ?? null, }) .abortSignal(AbortSignal.timeout(10000)); if (claimed.error) throw new Error("Could not claim saved tournament announcements."); const client = new DiscordClient(config.token); // Claiming a batch must not make its last job wait through every other send: // the durable leases last two minutes; rate-limited sends return to the queue. const deliveries = await Promise.allSettled( (claimed.data ?? []).map((job) => deliverTournamentAnnouncement(job, { client, load: async () => { const tournament = await db .from("tournaments") .select( "id,name,owner_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,status,announce_discord", ) .eq("id", job.tournament_id) .abortSignal(AbortSignal.timeout(10000)) .maybeSingle(); if (tournament.error) throw new Error("Could not load tournament announcement."); if (!tournament.data) return null; const owner = tournament.data.owner_id ? await db .from("profiles") .select("display_name") .eq("id", tournament.data.owner_id) .abortSignal(AbortSignal.timeout(10000)) .maybeSingle() : { data: null, error: null }; if (owner.error) throw new Error("Could not load tournament organizer."); return { ...tournament.data, owner: owner.data?.display_name ?? "Explorer", }; }, finish: async (patch) => { const result = await db .from("tournament_announcements") .update(patch) .eq("id", job.id) .eq("lease_owner", worker) .select("id") .abortSignal(AbortSignal.timeout(10000)) .single(); if (result.error) throw new Error("Could not save tournament announcement delivery."); }, }), ), ); if (deliveries.some((result) => result.status === "rejected")) throw new Error( "Some tournament deliveries could not be saved; their leases will expire for retry.", ); }