bot: clean up inactive tickets
vi did:web:vt3e.cat
Sun, 28 Jun 2026 04:01:18 +0100
7 files changed,
165 insertions(+),
2 deletions(-)
M
apps/bot/src/feats/tickets/README.md
→
apps/bot/src/feats/tickets/README.md
@@ -32,3 +32,5 @@ - [x] permissions
- [x] add participants - [x] log messages - [x] private thread too + - [ ] unclaim after 15 minutes + - [ ] close & delete after 15 minutes of no activity
M
apps/bot/src/feats/tickets/index.ts
→
apps/bot/src/feats/tickets/index.ts
@@ -1,6 +1,7 @@
import { TicketButtons } from "./buttons"; import ticketCommand from "./commands"; import { TicketModals } from "./modals"; +import "./timers"; export const ticketCommands = [ticketCommand]; export const ticketComponents = [...TicketModals, ...TicketButtons];
M
apps/bot/src/feats/tickets/listeners.ts
→
apps/bot/src/feats/tickets/listeners.ts
@@ -4,6 +4,7 @@ import { Result } from "@sapphire/framework";
import { createTicketMessageWithAttachments, deleteTicketMessage, + editTicket, editTicketMessage, getTicketByChannelId, getTicketMessageByMessageId,@@ -169,6 +170,16 @@ fileSize: status.attachment.size,
})), }), ); + + if (authorDb.id === ticket?.claimedBy) { + await editTicket(ticket.id, { + lastClaimantActivityAt: new Date(), + }); + } else if (authorDb.id === ticket.subject) { + await editTicket(ticket.id, { + lastSubjectActivityAt: new Date(), + }); + } if (messageResult.isErr()) { logger.error(messageResult.unwrapErr(), `Failed to create ticket message with attachments`);
A
apps/bot/src/feats/tickets/timers.ts
@@ -0,0 +1,68 @@
+import { editTicket, getOpenTickets } from "@stealth-developers/db"; + +import { getTextChannel } from "@/lib"; +import { addTimer } from "@/timers"; + +import { getGuildActor } from "../guild-actor"; +import { channelName, closeTicket, messageChannel } from "./actions"; + +// time in minutes before a ticket is considered inactive +const CLAIMANT_INACTIVITY_THRESHOLD = 0.25; +const SUBJECT_INACTIVITY_THRESHOLD = 0.25; + +addTimer({ + name: "Check if tickets are inactive", + interval: 1, + callback: async () => { + const openTickets = await getOpenTickets(); + + for (const ticket of openTickets) { + const { lastClaimantActivityAt, lastSubjectActivityAt } = ticket; + + if (lastClaimantActivityAt) { + const now = Date.now(); + const timeSinceLastActivity = now - lastClaimantActivityAt.getTime(); + if (timeSinceLastActivity / 60_000 > CLAIMANT_INACTIVITY_THRESHOLD && ticket.channelId) { + const channel = await getTextChannel(ticket.channelId); + editTicket(ticket.id, { + claimedAt: null, + claimedBy: null, + lastClaimantActivityAt: null, + }); + + messageChannel(channel.id, { + content: `This ticket has been automatically unclaimed due to inactivity, someone else will be with you shortly.`, + }); + + channel + .edit({ + name: channelName(ticket.localId, "open"), + }) + .catch(); + } + + continue; + } + + if (!lastSubjectActivityAt && ticket.guild) { + const now = Date.now(); + const timeSinceLastActivity = now - ticket.openedAt.getTime(); + + if (timeSinceLastActivity / 60_000 > SUBJECT_INACTIVITY_THRESHOLD) { + const guildActor = getGuildActor(ticket.guild.discordId); + if (!guildActor) continue; + + await closeTicket( + guildActor, + ticket.id, + { + public: `Ticket closed after ${SUBJECT_INACTIVITY_THRESHOLD} minutes of inactivity`, + private: null, + }, + true, + ); + } + } + } + }, +});
M
apps/bot/src/lib/channels.ts
→
apps/bot/src/lib/channels.ts
@@ -1,11 +1,13 @@
+import type { TextChannel } from "discord.js"; + import { ChannelType } from "discord.js"; import { client } from "@/client"; -export async function getTextChannel(id: string, mustBeSendable = true) { +export async function getTextChannel(id: string, mustBeSendable = true): Promise<TextChannel> { const channel = await client.channels.fetch(id); if (!channel) throw new Error(`Channel not found: ${id}`); - if (!channel.isTextBased()) throw new Error(`Channel is not text-based: ${id}`); + if (channel.type !== ChannelType.GuildText) throw new Error(`Channel is not text-based: ${id}`); if (!mustBeSendable) return channel; if (!channel.isSendable()) throw new Error(`Channel is not sendable: ${id}`);
A
apps/bot/src/timers/index.ts
@@ -0,0 +1,69 @@
+let timeElapsed = 0; +let intervalId: ReturnType<typeof setInterval> | null = null; + +export type TimerDeclaration = { + name: string; + interval: number; + callback: () => void; +}; + +const timers: TimerDeclaration[] = []; + +const nextFireTime: Map<string, number> = new Map(); + +export function addTimer(timer: TimerDeclaration): void { + if (timers.some((t) => t.name === timer.name)) { + console.warn(`timer with name "${timer.name}" is already registered.`); + return; + } + + timers.push(timer); + nextFireTime.set(timer.name, timeElapsed + timer.interval); +} + +export function removeTimer(name: string): void { + const index = timers.findIndex((t) => t.name === name); + if (index !== -1) { + timers.splice(index, 1); + nextFireTime.delete(name); + } +} + +export function startSystem(): void { + if (intervalId !== null) { + console.warn("timer is already running."); + return; + } + + intervalId = setInterval(() => { + timeElapsed++; + + for (const timer of timers) { + const fireAt = nextFireTime.get(timer.name) ?? 0; + if (timeElapsed >= fireAt) { + timer.callback(); + nextFireTime.set(timer.name, timeElapsed + timer.interval); + } + } + }, 1000); +} + +export function stopSystem(): void { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } +} + +export function getTimeElapsed(): number { + return timeElapsed; +} + +export function resetSystem(): void { + stopSystem(); + timers.length = 0; + nextFireTime.clear(); + timeElapsed = 0; +} + +startSystem();
M
pkgs/db/src/utils/ticket.ts
→
pkgs/db/src/utils/ticket.ts
@@ -141,6 +141,15 @@ with: ticketWithRelations,
}); } +export async function getOpenTickets(): Promise<TicketWithRelations[]> { + return await db.query.tickets.findMany({ + where: { + status: "open", + }, + with: ticketWithRelations, + }); +} + export async function editTicket( ticketId: string, data: Partial<Ticket>,@@ -461,6 +470,7 @@ .update(tickets)
.set({ claimedBy: null, claimedAt: null, + lastClaimantActivityAt: null, }) .where(eq(tickets.id, ticketId)) .returning();