all repos — stealth-developers @ 930b53d1f2ecf63e281211eb4521c0cba2675320

feat: auto-archive tickets after 30 minutes of inactivity
vi v@vt3e.cat
Thu, 05 Mar 2026 02:21:56 +0000
commit

930b53d1f2ecf63e281211eb4521c0cba2675320

parent

b6e0941babbabd8366ca160ca09bb330a1698eb9

3 files changed, 177 insertions(+), 0 deletions(-)

jump to
M src/bot.tssrc/bot.ts

@@ -3,6 +3,7 @@

import registerEvents from "./handlers/events.ts"; import registerInteractions from "./handlers/interactions.ts"; +import { startTicketWatcher } from "./tasks/ticketWatcher.ts"; import logger from "./utils/logging.ts"; const client = new Client({

@@ -19,6 +20,8 @@

await Promise.all([registerEvents(client), registerInteractions(client)]); logger.info("events and interactions registered!"); console.log(); + + startTicketWatcher(client); }); export default client;
M src/database/schema/tickets.tssrc/database/schema/tickets.ts

@@ -28,6 +28,8 @@ closedBy: text("closed_by"),

closeReason: text("close_reason"), privateReason: text("private_reason"), + + warnedAt: integer("warned_at", { mode: "timestamp" }).$type<Date | null>(), }, (t) => [ index("tickets_guild_idx").on(t.guildId),
A src/tasks/ticketWatcher.ts

@@ -0,0 +1,172 @@

+import { db, ticketMessages, tickets } from "@/database"; +import { loggers } from "@/utils/logging"; +import { getGuild } from "@/utils/queries"; +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"); + }, + }; +}