Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
261 lines
8.5 KiB
TypeScript
261 lines
8.5 KiB
TypeScript
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: `<t:${Math.floor(timestamp / 1000)}:F> · <t:${Math.floor(timestamp / 1000)}:R>`,
|
|
inline: false,
|
|
},
|
|
],
|
|
footer: { text: "MineGeo · Community tournaments" },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function deliverTournamentAnnouncement(
|
|
job: TournamentAnnouncementJob,
|
|
dependencies: {
|
|
client: Pick<DiscordClient, "send">;
|
|
load: () => Promise<TournamentAnnouncementSource | null>;
|
|
finish: (patch: Partial<TournamentAnnouncementJob>) => Promise<void>;
|
|
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<Database>,
|
|
env: Record<string, string | undefined> = 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.",
|
|
);
|
|
}
|