Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
This commit is contained in:
260
lib/tournament-discord.ts
Normal file
260
lib/tournament-discord.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
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.",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user