diff --git a/config/config.example.ts b/config/config.example.ts index 76ac2177..bf6042b5 100644 --- a/config/config.example.ts +++ b/config/config.example.ts @@ -40,6 +40,7 @@ export default defineConfig("0.0.1", { ticketClaim: true, ticketUnclaim: true, ticketClose: true, + ticketReopen: true, ticketDelete: true, userAdded: true, userRemoved: true, diff --git a/i18n/en/index.ts b/i18n/en/index.ts index 103fef81..8d9b9baa 100644 --- a/i18n/en/index.ts +++ b/i18n/en/index.ts @@ -39,6 +39,9 @@ const en: BaseTranslation = { close: { description: "Close the current ticket" }, + reopen: { + description: "Reopen the current ticket" + }, cleardm: { description: "Clear the bot's ticket history from your DMs", starting: "Clearing your ticket DM history...", @@ -118,6 +121,7 @@ const en: BaseTranslation = { }, actions: { close_ticket: "Close Ticket", + reopen_ticket: "Reopen Ticket", claim_ticket: "Claim Ticket", unclaim_ticket: "Unclaim Ticket", delete_ticket: "Delete Ticket" @@ -148,6 +152,12 @@ const en: BaseTranslation = { only_closed_delete: "Only closed tickets can be deleted from this button.", only_staff_delete: "Only staff can delete this ticket." }, + reopen: { + not_ticket: "This channel is not a ticket.", + only_closed: "Only closed tickets can be reopened.", + only_staff: "Only staff can reopen this ticket.", + success: "Ticket reopened. Participant access and ticket actions have been restored." + }, transcript: { collecting_messages: "Collecting ticket messages...", creating: "Creating transcript...", @@ -267,6 +277,12 @@ const en: BaseTranslation = { details: "**Ticket**: #{ticketId:string} - {ticketTypeName:string}\n**Opened By**: {createdByMention:string}\n**Claim Status**: {claimStatus:string}\n**Open Age**: {ticketAge:string}\n**Reason**: {reason:string}\n**Transcript**: {transcriptStatus:string}" }, + ticket_reopened: { + title: "## Ticket Reopened", + action: "{actorMention:string} reopened {ticketChannelMention:string}.", + details: + "**Ticket**: #{ticketId:string} - {ticketTypeName:string}\n**Opened By**: {createdByMention:string}\n**Claim Status**: {claimStatus:string}\n**Ticket Age**: {ticketAge:string}" + }, ticket_deleted: { title: "## Ticket Deleted", action: "{actorMention:string} deleted {ticketChannelMention:string}.", diff --git a/i18n/fr/index.ts b/i18n/fr/index.ts index 9642c31d..01015eff 100644 --- a/i18n/fr/index.ts +++ b/i18n/fr/index.ts @@ -39,6 +39,9 @@ const fr: Translation = { close: { description: "Fermer le ticket actuel" }, + reopen: { + description: "Rouvrir le ticket actuel" + }, cleardm: { description: "Effacer l'historique des tickets du bot dans vos MP", starting: "Suppression de votre historique de tickets en MP...", @@ -118,6 +121,7 @@ const fr: Translation = { }, actions: { close_ticket: "Fermer le ticket", + reopen_ticket: "Rouvrir le ticket", claim_ticket: "Prendre en charge", unclaim_ticket: "Libérer le ticket", delete_ticket: "Supprimer le ticket" @@ -148,6 +152,12 @@ const fr: Translation = { only_closed_delete: "Seuls les tickets fermés peuvent être supprimés depuis ce bouton.", only_staff_delete: "Seul le staff peut supprimer ce ticket." }, + reopen: { + not_ticket: "Ce salon n'est pas un ticket.", + only_closed: "Seuls les tickets fermés peuvent être rouverts.", + only_staff: "Seul le staff peut rouvrir ce ticket.", + success: "Ticket rouvert. Les accès des participants et les actions du ticket ont été restaurés." + }, transcript: { collecting_messages: "Collecte des messages du ticket...", creating: "Création de la transcription...", @@ -265,6 +275,12 @@ const fr: Translation = { details: "**Ticket** : #{ticketId} - {ticketTypeName}\n**Ouvert par** : {createdByMention}\n**Statut de prise en charge** : {claimStatus}\n**Ancienneté** : {ticketAge}\n**Raison** : {reason}\n**Transcription** : {transcriptStatus}" }, + ticket_reopened: { + title: "## Ticket rouvert", + action: "{actorMention} a rouvert {ticketChannelMention}.", + details: + "**Ticket** : #{ticketId} - {ticketTypeName}\n**Ouvert par** : {createdByMention}\n**Statut de prise en charge** : {claimStatus}\n**Ancienneté** : {ticketAge}" + }, ticket_deleted: { title: "## Ticket supprimé", action: "{actorMention} a supprimé {ticketChannelMention}.", diff --git a/i18n/i18n-types.ts b/i18n/i18n-types.ts index 2ba81c8c..6b3a467e 100644 --- a/i18n/i18n-types.ts +++ b/i18n/i18n-types.ts @@ -5,7 +5,7 @@ import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredP export type BaseTranslation = BaseTranslationType; export type BaseLocale = "en"; -export type Locales = "en" | "fr"; +export type Locales = "fr" | "en"; export type Translation = RootTranslation; @@ -118,6 +118,12 @@ type RootTranslation = { */ description: string; }; + reopen: { + /** + * R​e​o​p​e​n​ ​t​h​e​ ​c​u​r​r​e​n​t​ ​t​i​c​k​e​t + */ + description: string; + }; cleardm: { /** * C​l​e​a​r​ ​t​h​e​ ​b​o​t​'​s​ ​t​i​c​k​e​t​ ​h​i​s​t​o​r​y​ ​f​r​o​m​ ​y​o​u​r​ ​D​M​s @@ -340,6 +346,10 @@ type RootTranslation = { * C​l​o​s​e​ ​T​i​c​k​e​t */ close_ticket: string; + /** + * R​e​o​p​e​n​ ​T​i​c​k​e​t + */ + reopen_ticket: string; /** * C​l​a​i​m​ ​T​i​c​k​e​t */ @@ -439,6 +449,24 @@ type RootTranslation = { */ only_staff_delete: string; }; + reopen: { + /** + * T​h​i​s​ ​c​h​a​n​n​e​l​ ​i​s​ ​n​o​t​ ​a​ ​t​i​c​k​e​t​. + */ + not_ticket: string; + /** + * O​n​l​y​ ​c​l​o​s​e​d​ ​t​i​c​k​e​t​s​ ​c​a​n​ ​b​e​ ​r​e​o​p​e​n​e​d​. + */ + only_closed: string; + /** + * O​n​l​y​ ​s​t​a​f​f​ ​c​a​n​ ​r​e​o​p​e​n​ ​t​h​i​s​ ​t​i​c​k​e​t​. + */ + only_staff: string; + /** + * T​i​c​k​e​t​ ​r​e​o​p​e​n​e​d​.​ ​P​a​r​t​i​c​i​p​a​n​t​ ​a​c​c​e​s​s​ ​a​n​d​ ​t​i​c​k​e​t​ ​a​c​t​i​o​n​s​ ​h​a​v​e​ ​b​e​e​n​ ​r​e​s​t​o​r​e​d​. + */ + success: string; + }; transcript: { /** * C​o​l​l​e​c​t​i​n​g​ ​t​i​c​k​e​t​ ​m​e​s​s​a​g​e​s​.​.​. @@ -872,6 +900,30 @@ type RootTranslation = { "claimStatus" | "createdByMention" | "reason" | "ticketAge" | "ticketId" | "ticketTypeName" | "transcriptStatus" >; }; + ticket_reopened: { + /** + * #​#​ ​T​i​c​k​e​t​ ​R​e​o​p​e​n​e​d + */ + title: string; + /** + * {​a​c​t​o​r​M​e​n​t​i​o​n​}​ ​r​e​o​p​e​n​e​d​ ​{​t​i​c​k​e​t​C​h​a​n​n​e​l​M​e​n​t​i​o​n​}​. + * @param {string} actorMention + * @param {string} ticketChannelMention + */ + action: RequiredParams<"actorMention" | "ticketChannelMention">; + /** + * *​*​T​i​c​k​e​t​*​*​:​ ​#​{​t​i​c​k​e​t​I​d​}​ ​-​ ​{​t​i​c​k​e​t​T​y​p​e​N​a​m​e​}​ + ​*​*​O​p​e​n​e​d​ ​B​y​*​*​:​ ​{​c​r​e​a​t​e​d​B​y​M​e​n​t​i​o​n​}​ + ​*​*​C​l​a​i​m​ ​S​t​a​t​u​s​*​*​:​ ​{​c​l​a​i​m​S​t​a​t​u​s​}​ + ​*​*​T​i​c​k​e​t​ ​A​g​e​*​*​:​ ​{​t​i​c​k​e​t​A​g​e​} + * @param {string} claimStatus + * @param {string} createdByMention + * @param {string} ticketAge + * @param {string} ticketId + * @param {string} ticketTypeName + */ + details: RequiredParams<"claimStatus" | "createdByMention" | "ticketAge" | "ticketId" | "ticketTypeName">; + }; ticket_deleted: { /** * #​#​ ​T​i​c​k​e​t​ ​D​e​l​e​t​e​d @@ -1074,6 +1126,12 @@ export type TranslationFunctions = { */ description: () => LocalizedString; }; + reopen: { + /** + * Reopen the current ticket + */ + description: () => LocalizedString; + }; cleardm: { /** * Clear the bot's ticket history from your DMs @@ -1285,6 +1343,10 @@ export type TranslationFunctions = { * Close Ticket */ close_ticket: () => LocalizedString; + /** + * Reopen Ticket + */ + reopen_ticket: () => LocalizedString; /** * Claim Ticket */ @@ -1384,6 +1446,24 @@ export type TranslationFunctions = { */ only_staff_delete: () => LocalizedString; }; + reopen: { + /** + * This channel is not a ticket. + */ + not_ticket: () => LocalizedString; + /** + * Only closed tickets can be reopened. + */ + only_closed: () => LocalizedString; + /** + * Only staff can reopen this ticket. + */ + only_staff: () => LocalizedString; + /** + * Ticket reopened. Participant access and ticket actions have been restored. + */ + success: () => LocalizedString; + }; transcript: { /** * Collecting ticket messages... @@ -1763,6 +1843,29 @@ export type TranslationFunctions = { transcriptStatus: string; }) => LocalizedString; }; + ticket_reopened: { + /** + * ## Ticket Reopened + */ + title: () => LocalizedString; + /** + * {actorMention} reopened {ticketChannelMention}. + */ + action: (arg: { actorMention: string; ticketChannelMention: string }) => LocalizedString; + /** + * **Ticket**: #{ticketId} - {ticketTypeName} + **Opened By**: {createdByMention} + **Claim Status**: {claimStatus} + **Ticket Age**: {ticketAge} + */ + details: (arg: { + claimStatus: string; + createdByMention: string; + ticketAge: string; + ticketId: string; + ticketTypeName: string; + }) => LocalizedString; + }; ticket_deleted: { /** * ## Ticket Deleted diff --git a/messages/logs/ticket-reopened.ts b/messages/logs/ticket-reopened.ts new file mode 100644 index 00000000..e04bf149 --- /dev/null +++ b/messages/logs/ticket-reopened.ts @@ -0,0 +1,63 @@ +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ + +import { ComponentType } from "@discordjs/core"; +import type { LoadedMessageTemplate, MessageTemplateContext } from "@/features/tickets/types"; + +const ticketReopenedLogMessage = ({ LL }: MessageTemplateContext): LoadedMessageTemplate => ({ + components: [ + { + type: ComponentType.Container, + accent_color: 5763719, + components: [ + { type: ComponentType.TextDisplay, content: LL.logs.templates.ticket_reopened.title() }, + { + type: ComponentType.TextDisplay, + content: LL.logs.templates.ticket_reopened.action({ + actorMention: "{actorMention}", + ticketChannelMention: "{ticketChannelMention}" + }) + }, + { + type: ComponentType.TextDisplay, + content: LL.logs.templates.ticket_reopened.details({ + ticketId: "{ticketId}", + ticketTypeName: "{ticketTypeName}", + createdByMention: "{createdByMention}", + claimStatus: "{claimStatus}", + ticketAge: "{ticketAge}" + }) + } + ] + } + ] +}); + +export default ticketReopenedLogMessage; + +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ diff --git a/messages/tickets/ticket-closed-billing.ts b/messages/tickets/ticket-closed-billing.ts index b258c6be..b7e38706 100644 --- a/messages/tickets/ticket-closed-billing.ts +++ b/messages/tickets/ticket-closed-billing.ts @@ -45,6 +45,12 @@ const ticketClosedBillingMessage = ({ LL }: MessageTemplateContext): LoadedMessa { type: ComponentType.ActionRow, components: [ + { + type: ComponentType.Button, + custom_id: "{reopenButtonCustomId}", + label: LL.tickets.actions.reopen_ticket(), + style: 3 + }, { type: ComponentType.Button, custom_id: "{deleteButtonCustomId}", diff --git a/messages/tickets/ticket-closed-general.ts b/messages/tickets/ticket-closed-general.ts index a7a60123..49974e40 100644 --- a/messages/tickets/ticket-closed-general.ts +++ b/messages/tickets/ticket-closed-general.ts @@ -45,6 +45,12 @@ const ticketClosedGeneralMessage = ({ LL }: MessageTemplateContext): LoadedMessa { type: ComponentType.ActionRow, components: [ + { + type: ComponentType.Button, + custom_id: "{reopenButtonCustomId}", + label: LL.tickets.actions.reopen_ticket(), + style: 3 + }, { type: ComponentType.Button, custom_id: "{deleteButtonCustomId}", diff --git a/messages/tickets/ticket-closed-report.ts b/messages/tickets/ticket-closed-report.ts index 4e821a6d..6c73241d 100644 --- a/messages/tickets/ticket-closed-report.ts +++ b/messages/tickets/ticket-closed-report.ts @@ -45,6 +45,12 @@ const ticketClosedReportMessage = ({ LL }: MessageTemplateContext): LoadedMessag { type: ComponentType.ActionRow, components: [ + { + type: ComponentType.Button, + custom_id: "{reopenButtonCustomId}", + label: LL.tickets.actions.reopen_ticket(), + style: 3 + }, { type: ComponentType.Button, custom_id: "{deleteButtonCustomId}", diff --git a/messages/tickets/ticket-closed.ts b/messages/tickets/ticket-closed.ts index 5dda4ba7..3a874f75 100644 --- a/messages/tickets/ticket-closed.ts +++ b/messages/tickets/ticket-closed.ts @@ -45,6 +45,12 @@ const ticketClosedMessage = ({ LL }: MessageTemplateContext): LoadedMessageTempl { type: ComponentType.ActionRow, components: [ + { + type: ComponentType.Button, + custom_id: "{reopenButtonCustomId}", + label: LL.tickets.actions.reopen_ticket(), + style: 3 + }, { type: ComponentType.Button, custom_id: "{deleteButtonCustomId}", diff --git a/src/config/index.ts b/src/config/index.ts index dccf63ec..9444beb2 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -36,6 +36,7 @@ interface ConfigV0_0_1 { ticketClaim?: boolean; ticketUnclaim?: boolean; ticketClose?: boolean; + ticketReopen?: boolean; ticketDelete?: boolean; userAdded?: boolean; userRemoved?: boolean; diff --git a/src/features/commands/reopen/command.ts b/src/features/commands/reopen/command.ts new file mode 100644 index 00000000..3c420dfc --- /dev/null +++ b/src/features/commands/reopen/command.ts @@ -0,0 +1,40 @@ +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ + +import { defineCommand } from "@/core/defineCommand"; +import { executeReopenCommand } from "@/features/tickets/reopen-workflow"; + +export default defineCommand({ + data: (LL) => ({ + name: "reopen", + description: LL.commands.reopen.description() + }), + execute: executeReopenCommand +}); + +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ diff --git a/src/features/logs/service.ts b/src/features/logs/service.ts index 152a95cf..5022f8bb 100644 --- a/src/features/logs/service.ts +++ b/src/features/logs/service.ts @@ -24,6 +24,7 @@ const LOG_EVENT_TOGGLE_KEYS: Record = ticketClaim: "ticketClaim", ticketUnclaim: "ticketUnclaim", ticketClose: "ticketClose", + ticketReopen: "ticketReopen", ticketDelete: "ticketDelete", userAdded: "userAdded", userRemoved: "userRemoved", @@ -35,6 +36,7 @@ const LOG_TEMPLATE_REFERENCES: Record = { ticketClaim: "logs/ticket-claimed", ticketUnclaim: "logs/ticket-unclaimed", ticketClose: "logs/ticket-closed", + ticketReopen: "logs/ticket-reopened", ticketDelete: "logs/ticket-deleted", userAdded: "logs/user-added", userRemoved: "logs/user-removed", diff --git a/src/features/logs/types.ts b/src/features/logs/types.ts index ca4c539f..c77f7edf 100644 --- a/src/features/logs/types.ts +++ b/src/features/logs/types.ts @@ -49,6 +49,10 @@ export interface TicketCloseLogEvent extends BaseTicketLogEvent { transcriptUrl?: string | null; } +export interface TicketReopenLogEvent extends BaseTicketLogEvent { + kind: "ticketReopen"; +} + export interface TicketDeleteLogEvent extends BaseTicketLogEvent { kind: "ticketDelete"; reason: string; @@ -76,6 +80,7 @@ export type TicketLogEvent = | TicketClaimLogEvent | TicketUnclaimLogEvent | TicketCloseLogEvent + | TicketReopenLogEvent | TicketDeleteLogEvent | UserAddedLogEvent | UserRemovedLogEvent diff --git a/src/features/tickets/close-workflow.ts b/src/features/tickets/close-workflow.ts index fa4fbdaa..18bfa0fa 100644 --- a/src/features/tickets/close-workflow.ts +++ b/src/features/tickets/close-workflow.ts @@ -566,14 +566,26 @@ async function sendCloseDm( async function buildCloseChannelMessage(app: BotApp, ticketType: ReturnType, tokens: CloseMessageTokens) { const deleteButtonCustomId = createCustomId("tickets", "delete-closed"); + const reopenButtonCustomId = createCustomId("tickets", "reopen"); const messageTemplate = await loadMessageTemplate(app, resolveCloseChannelMessageReference(app, ticketType), { ...tokens, - deleteButtonCustomId + deleteButtonCustomId, + reopenButtonCustomId }); return finalizeMessageTemplate( appendMessageButton( - messageTemplate, + appendMessageButton( + messageTemplate, + !hasMessageComponentCustomId(messageTemplate, reopenButtonCustomId) + ? ({ + type: ComponentType.Button, + custom_id: reopenButtonCustomId, + label: app.LL.tickets.actions.reopen_ticket(), + style: ButtonStyle.Success + } satisfies APIButtonComponentWithCustomId) + : undefined + ), !hasMessageComponentCustomId(messageTemplate, deleteButtonCustomId) ? ({ type: ComponentType.Button, diff --git a/src/features/tickets/feature.ts b/src/features/tickets/feature.ts index 82344375..eb069f42 100644 --- a/src/features/tickets/feature.ts +++ b/src/features/tickets/feature.ts @@ -17,6 +17,7 @@ import { defineFeature } from "@/core/defineFeature"; import { handleRemoveUsersSelect } from "@/features/commands/remove/command"; import { handleClaimButton, handleUnclaimButton } from "@/features/tickets/claim-workflow"; import { handleCloseButton, handleCloseReasonSubmit, handleDeleteClosedTicketButton } from "@/features/tickets/close-workflow"; +import { handleReopenButton } from "@/features/tickets/reopen-workflow"; import { handleOpenFormSubmit, handleOpenPanelSelector, handlePanelButtons, handlePanelSelect } from "@/features/tickets/service"; const ticketsFeature = defineFeature({ @@ -27,6 +28,7 @@ const ticketsFeature = defineFeature({ "delete-closed": handleDeleteClosedTicketButton, "open-select": handleOpenPanelSelector, "open-type": handlePanelButtons, + reopen: handleReopenButton, unclaim: handleUnclaimButton }, stringSelects: { diff --git a/src/features/tickets/reopen-workflow.ts b/src/features/tickets/reopen-workflow.ts new file mode 100644 index 00000000..4c45ea4f --- /dev/null +++ b/src/features/tickets/reopen-workflow.ts @@ -0,0 +1,159 @@ +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ + +import type { APIChatInputApplicationCommandInteraction, APIMessageComponentInteraction } from "@discordjs/core"; +import { MessageFlags } from "@discordjs/core"; +import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import { deferReply, editReply, reply } from "@/core/respond"; +import type { BotApp, CommandExecutionContext, ComponentExecutionContext } from "@/core/types"; +import { ticketsTable } from "@/db/schema"; +import { sendTicketLog } from "@/features/logs/service"; +import { createTicketLogContext } from "@/features/logs/utils"; +import { getTicketType, hasTicketStaffAccess } from "@/features/tickets/config-access"; +import { getInvitedUserIds, grantTicketParticipantAccess } from "@/features/tickets/participants"; +import { findTicketByChannel } from "@/features/tickets/records"; +import { syncTicketWelcomeMessage } from "@/features/tickets/ticket-workflow"; +import { getInteractionUser, getMemberRoleIds } from "@/features/tickets/utils"; + +type ReopenInteraction = APIChatInputApplicationCommandInteraction | APIMessageComponentInteraction; + +export async function executeReopenCommand( + context: CommandExecutionContext, + interaction: APIChatInputApplicationCommandInteraction +) { + await reopenTicket(context.app, interaction); +} + +export async function handleReopenButton(context: ComponentExecutionContext, interaction: APIMessageComponentInteraction) { + await reopenTicket(context.app, interaction); +} + +async function reopenTicket(app: BotApp, interaction: ReopenInteraction) { + const channelId = interaction.channel_id; + + if (!channelId) { + await reply(app, interaction, { + content: app.LL.tickets.records.not_ticket_channel(), + flags: MessageFlags.Ephemeral + }); + return; + } + + const ticket = await findTicketByChannel(app, channelId); + + if (!ticket) { + await reply(app, interaction, { + content: app.LL.tickets.reopen.not_ticket(), + flags: MessageFlags.Ephemeral + }); + return; + } + + if (!ticket.closedAt) { + await reply(app, interaction, { + content: app.LL.tickets.reopen.only_closed(), + flags: MessageFlags.Ephemeral + }); + return; + } + + const ticketType = getTicketType(app, ticket.type); + + if (!hasTicketStaffAccess(app, ticketType, getMemberRoleIds(interaction))) { + await reply(app, interaction, { + content: app.LL.tickets.reopen.only_staff(), + flags: MessageFlags.Ephemeral + }); + return; + } + + await deferReply(app, interaction, { flags: MessageFlags.Ephemeral }); + + const [reopenedTicket] = await app.db + .update(ticketsTable) + .set({ + closedAt: null, + closedBy: null, + closedReason: null, + transcriptUrl: null + }) + .where(and(eq(ticketsTable.channelId, channelId), isNotNull(ticketsTable.closedAt))) + .returning(); + + // Two staff members may use the button at almost the same time. The guarded + // update makes the first interaction authoritative without duplicate logs. + if (!reopenedTicket) { + await editReply(app, interaction, { + content: app.LL.tickets.reopen.only_closed() + }); + return; + } + + try { + const participantIds = [ticket.createdBy, ...getInvitedUserIds(ticket)]; + const categoryId = + ticket.claimedBy && app.config.tickets.claims.categoryWhenClaimed?.trim() + ? app.config.tickets.claims.categoryWhenClaimed.trim() + : ticketType.categoryId; + + await Promise.all([ + ...participantIds.map((userId) => grantTicketParticipantAccess(app, channelId, userId)), + app.client.api.channels.edit(channelId, { parent_id: categoryId }) + ]); + await syncTicketWelcomeMessage(app, reopenedTicket, ticketType); + } catch (error) { + // Restore the closed record when Discord rejects a required update. This + // avoids presenting a ticket as open while its participants cannot use it. + await app.db + .update(ticketsTable) + .set({ + closedAt: ticket.closedAt, + closedBy: ticket.closedBy, + closedReason: ticket.closedReason, + transcriptUrl: ticket.transcriptUrl + }) + .where(and(eq(ticketsTable.channelId, channelId), isNull(ticketsTable.closedAt))); + app.logger.error(`Failed to restore Discord state for ticket channel ${channelId}.`, error); + await editReply(app, interaction, { + content: app.LL.shared.unexpected_interaction_error() + }); + return; + } + + void sendTicketLog(app, { + kind: "ticketReopen", + actor: getInteractionUser(interaction), + ticket: createTicketLogContext(reopenedTicket, ticketType.name) + }); + + await editReply(app, interaction, { + content: app.LL.tickets.reopen.success() + }); +} + +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ diff --git a/src/features/tickets/transcripts.ts b/src/features/tickets/transcripts.ts index 6032715a..d14b65dc 100644 --- a/src/features/tickets/transcripts.ts +++ b/src/features/tickets/transcripts.ts @@ -15,7 +15,7 @@ This notice must not be removed, obscured, or replaced. import { TicketPmUploadClient } from "@ticketpm/core"; import { buildEnrichedDiscordApiTranscriptData } from "@ticketpm/discord-api"; -import { eq } from "drizzle-orm"; +import { and, eq, isNotNull } from "drizzle-orm"; import type { BotApp } from "@/core/types"; import { ticketsTable } from "@/db/schema"; @@ -41,7 +41,12 @@ export async function startTranscriptJob( const promise = createTranscript(app, ticketChannelId, options?.onStatus) .then(async (transcriptUrl) => { if (transcriptUrl) { - await app.db.update(ticketsTable).set({ transcriptUrl }).where(eq(ticketsTable.channelId, ticketChannelId)); + // A timed-out upload can finish after staff reopen the ticket. Only + // associate the transcript while the ticket is still closed. + await app.db + .update(ticketsTable) + .set({ transcriptUrl }) + .where(and(eq(ticketsTable.channelId, ticketChannelId), isNotNull(ticketsTable.closedAt))); } return transcriptUrl;