import { db, ticketMessages, tickets } from "@/database"; import { getGuild } from "@/database/queries"; import { loggers } from "@/utils/logging"; import type { Client } from "discord.js"; import { and, eq } from "drizzle-orm"; const logger = loggers.events.child({ name: "ticketWatcher" }); const WARN_MS = 15 * 60 * 1000; const CLOSE_MS = 30 * 60 * 1000; const INTERVAL_MS = 60 * 1000; export function startTicketWatcher(client: Client) { if (!client) throw new Error("client is required to start ticket watcher"); logger.info("starting ticket watcher"); let running = false; const run = async () => { if (running) { logger.warn( "previous ticket watcher run still in progress, skipping this tick", ); return; } running = true; try { const openTickets = await db .select() .from(tickets) .where(eq(tickets.status, "open")) .execute(); for (const ticket of openTickets) { try { const createdAt = ticket.createdAt ? new Date(ticket.createdAt).getTime() : null; if (!createdAt) continue; const age = Date.now() - createdAt; const userMsgs = await db .select() .from(ticketMessages) .where( and( eq(ticketMessages.ticketId, ticket.id), eq(ticketMessages.authorType, "user"), ), ) .execute(); const hasUserMessages = userMsgs && userMsgs.length > 0; if (hasUserMessages) continue; if (!ticket.warnedAt && age >= WARN_MS && age < CLOSE_MS) { if (ticket.channelId && ticket.guildId) { try { const channel = await client.channels .fetch(ticket.channelId) .catch(() => null); if (channel?.isSendable()) { await channel.send( `<@${ticket.authorId}>, this ticket will be automatically archived in 15 minutes due to inactivity. Please send a message in this channel to keep it open.`, ); } } catch (err) { logger.error(err, `failed to warn for ticket ${ticket.id}`); } } await db .update(tickets) .set({ warnedAt: new Date() }) .where(eq(tickets.id, ticket.id)) .execute(); logger.info(`warned ticket ${ticket.id}`); continue; } if (age >= CLOSE_MS) { await db .update(tickets) .set({ status: "archived", closedAt: new Date(), closedBy: "system", closeReason: "Auto-archived due to inactivity", }) .where(eq(tickets.id, ticket.id)) .execute(); if (ticket.channelId && ticket.guildId) { try { const channel = await client.channels .fetch(ticket.channelId) .catch(() => null); if (!channel) continue; await channel.delete( `Auto-archived due to inactivity for ticket ${ticket.id}`, ); const authorId = ticket.authorId; if (authorId) { const user = await client.users .fetch(authorId) .catch(() => null); if (user) { await user .send( `Your ticket #${ticket.id} has been archived due to inactivity. You can open a new ticket at any time.`, ) .catch(() => null); } } const { data: guildData, exists: guildExists } = await getGuild( ticket.guildId, ); if (!guildExists) continue; const transcriptChannelId = guildData?.ticket_channel_id; if (!transcriptChannelId) continue; const transcriptChannel = await client.channels .fetch(transcriptChannelId) .catch(() => null); if (!transcriptChannel?.isTextBased()) continue; if (transcriptChannel.isSendable()) await transcriptChannel.send({ content: `Ticket #${ticket.id} has been archived due to inactivity.`, }); } catch (err) { logger.error( err, `failed to delete channel for ticket ${ticket.id}`, ); } } logger.info( `archived and deleted ticket ${ticket.id} due to inactivity`, ); } } catch (errTicket) { logger.error(errTicket, `error processing ticket ${ticket.id}`); } } } catch (err) { logger.error(err, "ticket watcher run failed"); } finally { running = false; } }; void run(); const id = setInterval(run, INTERVAL_MS); return { stop() { clearInterval(id); logger.info("stopped ticket watcher"); }, }; }