Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 150 additions & 10 deletions apps/bot/src/commands/staff/configuration-ticket-threads/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export default class extends Component.Interaction {
case 'allowed_author_actions': {
return this.authorActions({ interaction });
}
case 'author_leave_action': {
return this.authorLeaveAction({ interaction });
}
case 'private':
case 'notification': {
return this.privateAndNotification({ interaction });
Expand Down Expand Up @@ -238,7 +241,7 @@ export default class extends Component.Interaction {

await interaction.deferUpdate();
const [row] = await database
.select({ allowedAuthorActions: ticketThreadsCategories.allowedAuthorActions })
.select()
.from(ticketThreadsCategories)
.where(and(eq(ticketThreadsCategories.id, categoryId), eq(ticketThreadsCategories.guildId, interaction.guildId)));

Expand All @@ -256,10 +259,10 @@ export default class extends Component.Interaction {

const authorPermissions = new ThreadTicketActionsPermissionBitField(row.allowedAuthorActions);
const selectMenu = new StringSelectMenuBuilder()
.setCustomId(customId('ticket_threads_category_configuration_allowed_author_actions', categoryId))
.setMinValues(1)
.setCustomId(customId('ticket_threads_category_configuration_allowed_author_actions', row.id))
.setMinValues(0)
.setMaxValues(Object.keys(ThreadTicketActionsPermissionBitField.Flags).length)
.setPlaceholder('Edit one of the following ticket author actions:')
.setPlaceholder('Edit the following ticket author actions:')
.setOptions(
new StringSelectMenuOptionBuilder()
.setEmoji('📝')
Expand Down Expand Up @@ -298,6 +301,79 @@ export default class extends Component.Interaction {
return interaction.editReply({ components: [rowBuilder] });
}

private async authorLeaveAction({ interaction }: Component.Context<'string'>) {
const { dynamicValue } = extractCustomId(interaction.customId);
const {
data: categoryId,
error,
success,
} = ticketThreadsCategoriesSelectSchema.shape.id.safeParse(Number(dynamicValue));

if (!success) {
return interaction.reply({
embeds: [
userEmbedError({ client: interaction.client, description: prettifyError(error), member: interaction.member }),
],
flags: [MessageFlags.Ephemeral],
});
}

await interaction.deferUpdate();
const [row] = await database
.select()
.from(ticketThreadsCategories)
.where(and(eq(ticketThreadsCategories.id, categoryId), eq(ticketThreadsCategories.guildId, interaction.guildId)));

if (!row) {
return interaction.editReply({
embeds: [
userEmbedError({
client: interaction.client,
description: 'No category with the given ID could be found.',
member: interaction.member,
}),
],
});
}

const actions = new ThreadTicketActionsPermissionBitField(row.authorLeaveAction, false);
const selectMenu = new StringSelectMenuBuilder()
.setCustomId(customId('ticket_threads_category_configuration_author_leave_action', row.id))
.setMinValues(0)
.setMaxValues(1)
.setPlaceholder('Edit one of the following ticket author leave actions:')
.setOptions(
new StringSelectMenuOptionBuilder()
.setEmoji('🔒')
.setLabel('Lock')
.setDescription('Toggle whether the ticket will be locked.')
.setValue('lock')
.setDefault(actions.has(ThreadTicketActionsPermissionBitField.Flags.Lock)),
new StringSelectMenuOptionBuilder()
.setEmoji('🗃')
.setLabel('Close')
.setDescription('Toggle whether the ticket well be closed.')
.setValue('close')
.setDefault(actions.has(ThreadTicketActionsPermissionBitField.Flags.Close)),
new StringSelectMenuOptionBuilder()
.setEmoji('🔐')
.setLabel('Lock & Close')
.setDescription('Toggle whether the ticket will be locked and closed.')
.setValue('lock_and_close')
.setDefault(actions.has(ThreadTicketActionsPermissionBitField.Flags.LockAndClose)),
new StringSelectMenuOptionBuilder()
.setEmoji('🗑')
.setLabel('Delete')
.setDescription('Toggle whether the ticket will be deleted.')
.setValue('delete')
.setDefault(actions.has(ThreadTicketActionsPermissionBitField.Flags.Delete)),
);

const rowBuilder = new ActionRowBuilder<StringSelectMenuBuilder>().setComponents(selectMenu);

return interaction.editReply({ components: [rowBuilder] });
}

private async privateAndNotification({ interaction }: Component.Context<'string'>) {
const { dynamicValue } = extractCustomId(interaction.customId, true);
const type = interaction.values.at(0)?.includes('private') ? 'private threads' : 'thread notification';
Expand Down Expand Up @@ -704,19 +780,83 @@ export class AuthorActions extends Component.Interaction {
interaction.values.includes(name) ? authorPermissions.set(flag) : authorPermissions.clear(flag);
}

await authorPermissions.updateAuthorPermissions(row.id, row.guildId);
await authorPermissions.updateAllowedAuthorActions(row.id, row.guildId);

interaction.editReply({
components: [],
embeds: [
userEmbed(interaction)
.setTitle('Updated the Thread Ticket Category')
.setDescription(
`${interaction.member} has edited the allowed author actions to: ${
interaction.values.length > 0
? ThreadTicketing.actionsBitfieldToNames(authorPermissions.permissions)
.map((name) => inlineCode(name))
.join(', ')
: 'None'
}.`,
),
],
});
return interaction.followUp({ components: configurationMenu(categoryId), embeds: interaction.message.embeds });
}
}

export class AuthorLeaveAction extends Component.Interaction {
public readonly customIds = [dynamicCustomId('ticket_threads_category_configuration_author_leave_action')];

@DeferUpdate
@HasGlobalConfiguration
public async execute({ interaction }: Component.Context<'string'>) {
const { dynamicValue } = extractCustomId(interaction.customId, true);
const {
data: categoryId,
error,
success,
} = ticketThreadsCategoriesSelectSchema.shape.id.safeParse(Number(dynamicValue));

if (!success) {
return interaction.editReply({
components: [],
embeds: [
userEmbedError({ client: interaction.client, description: prettifyError(error), member: interaction.member }),
],
});
}

const [row] = await database
.select()
.from(ticketThreadsCategories)
.where(and(eq(ticketThreadsCategories.id, categoryId), eq(ticketThreadsCategories.guildId, interaction.guildId)));

if (!row) {
return interaction.editReply({
embeds: [
userEmbedError({
client: interaction.client,
description: 'No category with the given ID could be found.',
member: interaction.member,
}),
],
});
}

const bit = ThreadTicketing.actionsAsKeyAndFlagsMap.get(interaction.values.at(0) as ThreadTicketing.KeyOfActions);
await database
.update(ticketThreadsCategories)
.set({ authorLeaveAction: bit ?? null })
.where(and(eq(ticketThreadsCategories.id, categoryId), eq(ticketThreadsCategories.guildId, interaction.guildId)));

interaction.editReply({
components: [],
embeds: [
userEmbed(interaction)
.setTitle('Updated the Thread Ticket Category')
.setDescription(
`${interaction.member} has edited the allowed author actions to: ${ThreadTicketing.actionsBitfieldToNames(
authorPermissions.permissions,
)
.map((name) => inlineCode(name))
.join(', ')}.`,
`${interaction.member} has edited the author leave action to: ${
// biome-ignore lint/style/noNonNullAssertion: It should exist.
bit ? inlineCode(ThreadTicketing.actionsBitfieldToNames(bit).at(0)!) : 'None'
}.`,
),
],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ export function configurationMenu(categoryId: number) {
.setLabel('Allowed Author Actions')
.setDescription('Change what actions the ticket author can use.')
.setValue('allowed_author_actions'),
new StringSelectMenuOptionBuilder()
.setEmoji('👋')
.setLabel('Author Leave Action')
.setDescription('Change the action to perform when the ticket author leaves the thread.')
.setValue('author_leave_action'),
new StringSelectMenuOptionBuilder()
.setEmoji('🛃')
.setLabel('Private Thread')
Expand Down
4 changes: 1 addition & 3 deletions apps/bot/src/events/ThreadDelete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ export default class extends Event.Handler {

@LogExceptions
public execute([thread]: Event.ArgumentsOf<this['name']>) {
const threadIsByBot = thread.ownerId === thread.client.user.id;

if (threadIsByBot) {
if (thread.ownerId === thread.client.user.id) {
void database
.delete(ticketsThreads)
.where(eq(ticketsThreads.threadId, thread.id))
Expand Down
84 changes: 84 additions & 0 deletions apps/bot/src/events/ThreadMembersUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
database,
eq,
ThreadTicketActionsPermissionBitField,
ticketsThreads,
ticketThreadsCategories,
} from '@ticketer/database';
import { Event, embed, userEmbed } from '@ticketer/djs-framework';
import { ChannelType, Colors, inlineCode, PermissionFlagsBits, userMention } from 'discord.js';
import { translate } from '@/i18n';
import { fetchChannel, LogExceptions } from '@/utils';

export default class extends Event.Handler {
public readonly name = Event.Name.ThreadMembersUpdate;

@LogExceptions
public async execute([_, removedMembers, thread]: Event.ArgumentsOf<this['name']>) {
if (thread.type !== ChannelType.PublicThread && thread.type !== ChannelType.PrivateThread) return;

const author = removedMembers.at(0);
if (!author) return;

const [row] = await database
.select({
authorId: ticketsThreads.authorId,
state: ticketsThreads.state,
authorLeaveAction: ticketThreadsCategories.authorLeaveAction,
logsChannelId: ticketThreadsCategories.logsChannelId,
})
.from(ticketsThreads)
.where(eq(ticketsThreads.threadId, thread.id))
.innerJoin(ticketThreadsCategories, eq(ticketsThreads.categoryId, ticketThreadsCategories.id));

if (row?.authorId !== author.id || row.state !== 'active') return;

const logEmbed = author.guildMember
? userEmbed({ client: thread.client, member: author.guildMember })
: embed({ client: thread.client });
let state: 'lock' | 'close' | 'lockAndClose' | 'delete' = 'lock';

if (row.authorLeaveAction === ThreadTicketActionsPermissionBitField.Flags.Lock) {
if (!thread.manageable) return;
await thread.setLocked(true);
logEmbed.setColor(Colors.DarkVividPink);
} else if (row.authorLeaveAction === ThreadTicketActionsPermissionBitField.Flags.Close) {
if (!thread.editable) return;
await thread.setArchived(true);
logEmbed.setColor(Colors.Yellow);
state = 'close';
} else if (row.authorLeaveAction === ThreadTicketActionsPermissionBitField.Flags.LockAndClose) {
if (!thread.manageable || !thread.editable) return;
await thread.edit({ archived: true, locked: true });
logEmbed.setColor(Colors.DarkVividPink);
state = 'lockAndClose';
} else if (row.authorLeaveAction === ThreadTicketActionsPermissionBitField.Flags.Delete) {
if (!thread.manageable) return;
await thread.delete();
logEmbed.setColor(Colors.Red);
state = 'delete';
}

if (row.logsChannelId && row.authorLeaveAction) {
const me = await thread.guild.members.fetchMe();
const logsChannel = await fetchChannel(thread.guild, row.logsChannelId);

if (!logsChannel?.isTextBased()) return;
if (!logsChannel.permissionsFor(me).has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]))
return;

const translations = translate(thread.guild.preferredLocale).events.threadMembersUpdate.logs;
void logsChannel.send({
embeds: [
logEmbed.setTitle(translations.title()).setDescription(
translations.description({
member: userMention(author.id),
state,
thread: state === 'delete' ? `${inlineCode(thread.name)} (${thread.id})` : thread.toString(),
}),
),
],
});
}
}
}
2 changes: 1 addition & 1 deletion apps/bot/src/events/ThreadUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default class extends Event.Handler {
exists(database.select().from(ticketsThreads).where(eq(ticketsThreads.threadId, newThread.id)).limit(1)),
);

// Two events are sent when the thread is archived and the locked button is pressed. We pray for no race conditions 🙏.
// Two events are sent when the thread is archived but the locked button is pressed. We pray for no race conditions 🙏.
if (lockedAndArchived) {
return database.update(ticketsThreads).set({ state: 'lockedAndArchived' }).where(whereCondition);
} else if (unarchived || unlocked) {
Expand Down
7 changes: 7 additions & 0 deletions apps/bot/src/i18n/en-GB/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,13 @@ const en_GB = {
message: '{member:string} has left the server.',
},
},
threadMembersUpdate: {
logs: {
title: 'Ticket Author Left',
description:
'{member:string} left the thread at {thread:string} and therefore the ticket has been {state|{lock: locked, close: closed, lockAndClose: locked and closed, delete: deleted}}.',
},
},
},
miscellaneous: {
paginationButtons: {
Expand Down
27 changes: 27 additions & 0 deletions apps/bot/src/i18n/i18n-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,21 @@ type RootTranslation = {
message: RequiredParams<'member'>
}
}
threadMembersUpdate: {
logs: {
/**
* T​i​c​k​e​t​ ​A​u​t​h​o​r​ ​L​e​f​t
*/
title: string
/**
* {​m​e​m​b​e​r​}​ ​l​e​f​t​ ​t​h​e​ ​t​h​r​e​a​d​ ​a​t​ ​{​t​h​r​e​a​d​}​ ​a​n​d​ ​t​h​e​r​e​f​o​r​e​ ​t​h​e​ ​t​i​c​k​e​t​ ​h​a​s​ ​b​e​e​n​ ​{​s​t​a​t​e​|​{​l​o​c​k​:​ ​l​o​c​k​e​d​,​ ​c​l​o​s​e​:​ ​c​l​o​s​e​d​,​ ​l​o​c​k​A​n​d​C​l​o​s​e​:​ ​l​o​c​k​e​d​ ​a​n​d​ ​c​l​o​s​e​d​,​ ​d​e​l​e​t​e​:​ ​d​e​l​e​t​e​d​}​}​.
* @param {string} member
* @param {'lock' | 'close' | 'lockAndClose' | 'delete'} state
* @param {string} thread
*/
description: RequiredParams<'member' | `state|{lock:${string}, close:${string}, lockAndClose:${string}, delete:${string}}` | 'thread'>
}
}
}
miscellaneous: {
paginationButtons: {
Expand Down Expand Up @@ -3246,6 +3261,18 @@ export type TranslationFunctions = {
message: (arg: { member: string }) => LocalizedString
}
}
threadMembersUpdate: {
logs: {
/**
* Ticket Author Left
*/
title: () => LocalizedString
/**
* {member} left the thread at {thread} and therefore the ticket has been {state|{lock: locked, close: closed, lockAndClose: locked and closed, delete: deleted}}.
*/
description: (arg: { member: string, state: 'lock' | 'close' | 'lockAndClose' | 'delete', thread: string }) => LocalizedString
}
}
}
miscellaneous: {
paginationButtons: {
Expand Down
7 changes: 7 additions & 0 deletions apps/bot/src/i18n/sv-SE/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,13 @@ const sv_SE = {
message: '{member} har lämnat servern.',
},
},
threadMembersUpdate: {
logs: {
title: 'Stödbiljettsägare lämnade',
description:
'{member} lämnade tråden vid {thread} och därför har stödbiljetten {state|{lock: låsts, close: stängts, lockAndClose: låsts och stängts, delete: raderats}}.',
},
},
},
miscellaneous: {
paginationButtons: {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"turbo": "^2.7.3"
"turbo": "^2.7.4"
},
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48"
}
Loading