import { FatalError, RetryableError, getWorkflowMetadata, sleep, } from "workflow"; import { admin, required } from "./supabase"; import { DiscordClient, DiscordDeliveryError, discordConfiguration, } from "./discord-client"; export async function cleanupClosedDiscordParty(event: string, claim: string) { "use workflow"; const run = getWorkflowMetadata().workflowRunId; try { const deadline = await markClosed(event); if (deadline) { await sleep(new Date(deadline)); await deleteClosed(event); } } catch { await releaseFailedCleanup(event, claim, run); throw new Error( "Discord party cleanup failed; the saved announcement can be retried.", ); } } async function record(event: string) { const result = await admin() .from("discord_announcements") .select("*") .eq("id", event) .maybeSingle(); if (result.error) required(result); const job = result.data; return job?.kind === "party" && job.state === "sent" && job.closed_at && !job.deleted_at && job.channel_id && job.message_id ? job : null; } function client() { const config = discordConfiguration(process.env); if (!config.token || !config.origin) throw new FatalError( "Configure the Discord bot token and public site URL.", ); return { client: new DiscordClient(config.token), origin: config.origin! }; } function retry(error: unknown): never { if (error instanceof DiscordDeliveryError) { if (error.permanent) throw new FatalError(error.message); throw new RetryableError(error.message, { retryAfter: error.retryAfter * 1000, }); } throw new RetryableError("Discord cleanup could not be confirmed.", { retryAfter: "30s", }); } async function markClosed(event: string): Promise { "use step"; try { const job = await record(event); if (!job) return null; if (job.delete_after) return job.delete_after; const setup = client(); const exists = await setup.client.closeParty( job.channel_id!, job.message_id!, setup.origin, job.target_id, ); if (!exists) { required( await admin() .from("discord_announcements") .update({ deleted_at: new Date().toISOString(), cleanup_error: null }) .eq("id", event) .select("id") .single(), ); return null; } const result = await admin().rpc("mark_discord_party_closed", { p_event: event, }); if (result.error) required(result); return result.data; } catch (error) { if (error instanceof FatalError || error instanceof RetryableError) throw error; retry(error); } } markClosed.maxRetries = 10; async function deleteClosed(event: string) { "use step"; try { const job = await record(event); if (!job) return; if (!job.delete_after) throw new FatalError("The closed notice has not been confirmed."); const deadline = new Date(job.delete_after); if (deadline.getTime() > Date.now()) throw new RetryableError("Keep the five-minute grace period.", { retryAfter: deadline, }); await client().client.remove(job.channel_id!, job.message_id!); required( await admin() .from("discord_announcements") .update({ deleted_at: new Date().toISOString(), cleanup_error: null }) .eq("id", event) .select("id") .single(), ); } catch (error) { if (error instanceof FatalError || error instanceof RetryableError) throw error; retry(error); } } deleteClosed.maxRetries = 10; async function releaseFailedCleanup(event: string, claim: string, run: string) { "use step"; required( await admin() .from("discord_announcements") .update({ cleanup_run_id: null, cleanup_lease_until: new Date(Date.now() + 300000).toISOString(), cleanup_error: "Party cleanup failed. Check Discord setup and retry saved announcements.", }) .eq("id", event) .in("cleanup_run_id", [claim, run]) .is("deleted_at", null) .select("id"), ); console.error("Discord party cleanup failed", { event, run }); } releaseFailedCleanup.maxRetries = 10;