Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s

This commit is contained in:
2026-10-18 11:44:04 +05:00
parent ad73132f15
commit a7085e4e80
50 changed files with 4790 additions and 703 deletions

260
lib/tournament-discord.ts Normal file
View 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.",
);
}