/** * EphemeralRegistry — tracks ephemeral messages so they can be updated later * using the original interaction token (works within Discord's 15 minute window). * * Usage: * import { Ephemeral } from "@systems/ephemeral-registry"; * * // Store after sending followUp * const msg = await interaction.followUp({ ..., fetchReply: true }); * Ephemeral.store(voteId, "companion", interaction, msg.id); * * // Update later (within 15 minutes) * await Ephemeral.update(voteId, "companion", "✅ Done", []); * * // Clear all for a user (e.g. on poll start) * Ephemeral.clear(voteId); */ import { ButtonInteraction, ChatInputCommandInteraction, Message } from "discord.js"; type AnyInteraction = ChatInputCommandInteraction | ButtonInteraction; interface EphemeralEntry { interaction: AnyInteraction; messageId: string; storedAt: number; } const DISCORD_TOKEN_TTL_MS = 14 * 60 * 1000; // 14 min (Discord allows 15) const registry = new Map(); function makeKey(id: string, tag: string): string { return `${id}:${tag}`; } function isExpired(entry: EphemeralEntry): boolean { return Date.now() - entry.storedAt > DISCORD_TOKEN_TTL_MS; } export const Ephemeral = { /** * Store a reference to an ephemeral followUp message. * @param id — voter ID or userKey * @param tag — identifier (e.g. "companion", "conflict", "score_prompt") * @param interaction — the original interaction that sent the followUp * @param messageId — the message ID returned from fetchReply: true */ store(id: string, tag: string, interaction: AnyInteraction, messageId: string): void { registry.set(makeKey(id, tag), { interaction, messageId, storedAt: Date.now() }); }, /** * Get a stored entry (returns null if not found or expired). */ get(id: string, tag: string): EphemeralEntry | null { const entry = registry.get(makeKey(id, tag)); if (!entry) return null; if (isExpired(entry)) { registry.delete(makeKey(id, tag)); return null; } return entry; }, /** * Update a stored ephemeral message via the original interaction token. * Silently fails if not found, expired, or Discord rejects the edit. */ async update( id: string, tag: string, content: string, components: any[] = [], options: { final?: boolean } = { final: true } ): Promise { const entry = Ephemeral.get(id, tag); console.log(`[Ephemeral.update] id=${id} tag=${tag} found=${!!entry}`); if (!entry) return; try { console.log(`[Ephemeral.update] editing messageId=${entry.messageId}`); await entry.interaction.webhook.editMessage(entry.messageId, { content, components }); if (options.final !== false) Ephemeral.delete(id, tag); console.log(`[Ephemeral.update] success`); } catch (err: any) { console.error(`[Ephemeral.update] failed:`, err.message); Ephemeral.delete(id, tag); } }, /** * Delete a stored entry. */ delete(id: string, tag: string): void { registry.delete(makeKey(id, tag)); }, /** * Clear all ephemerals for a given ID. */ clear(id: string): void { for (const key of registry.keys()) { if (key.startsWith(`${id}:`)) registry.delete(key); } }, /** * Clear all entries (e.g. on full poll reset). */ clearAll(): void { registry.clear(); }, };