diff --git a/src/bot/commands/checkin/handlers/checkin-audit.ts b/src/bot/commands/checkin/handlers/checkin-audit.ts index d3ff30a..9cd3556 100644 --- a/src/bot/commands/checkin/handlers/checkin-audit.ts +++ b/src/bot/commands/checkin/handlers/checkin-audit.ts @@ -1,9 +1,9 @@ -import type { ChatInputCommandInteraction, Client } from 'discord.js' +import type { ChatInputCommandInteraction, Client, TextChannel } from 'discord.js' import { registerCommand } from '@commands/registry' import { AUDIT_FLAME_CHANNEL, FLAMEWARDEN_ROLE } from '@config/discord' import { CHECKIN_AUDIT_ID } from '@events/interaction-create/checkin/handlers/audit-modal' import { createCheckinReviewModal, encodeSnowflake, getCustomId } from '@utils/component' -import { sendReply } from '@utils/discord' +import { getChannelOrThread, sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' import { log } from '@utils/logger' import { SlashCommandBuilder } from 'discord.js' @@ -30,14 +30,20 @@ registerCommand({ if (!interaction.inCachedGuild()) throw new CheckinAuditError(CheckinAudit.ERR.NotGuild) - const channel = await CheckinAudit.assertAllowedChannel(interaction.guild, interaction.channelId, AUDIT_FLAME_CHANNEL) + const channel = await getChannelOrThread(interaction.guild, AUDIT_FLAME_CHANNEL) as TextChannel CheckinAudit.assertMissPerms(interaction.client.user, channel) + const thread = await CheckinAudit.assertThreadUnderChannel(interaction.guild, interaction.channelId, channel) + CheckinAudit.assertNotArchivedThread(thread) + CheckinAudit.assertNotPrivateThread(thread) + const threadMsg = await CheckinAudit.getThreadMessage(thread) + CheckinAudit.assertThreadMessageSendBy(threadMsg, interaction.client.user.id) const flamewarden = await interaction.guild.members.fetch(interaction.member.id) CheckinAudit.assertMember(flamewarden) CheckinAudit.assertMemberHasRole(flamewarden, FLAMEWARDEN_ROLE) const checkinId = interaction.options.getString('checkin-id', true) const checkin = await CheckinAudit.assertExistCheckinId(client.prisma, checkinId) + CheckinAudit.assertClarificationThread(thread, checkin.public_id) CheckinAudit.assertCheckinNotToday(checkin) const checkins = await CheckinAudit.getOldestWaitingCheckins(client.prisma, checkin.checkin_streak_id) CheckinAudit.assertCheckinWithOldestWaiting(checkin, checkins) diff --git a/src/bot/commands/checkin/handlers/checkin-status.ts b/src/bot/commands/checkin/handlers/checkin-status.ts index a119d10..fcccc87 100644 --- a/src/bot/commands/checkin/handlers/checkin-status.ts +++ b/src/bot/commands/checkin/handlers/checkin-status.ts @@ -1,8 +1,6 @@ import type { ChatInputCommandInteraction, Client, GuildMember, InteractionReplyOptions } from 'discord.js' -import { COMMAND_PATH } from '@commands/index' import { registerCommand } from '@commands/registry' import { AUDIT_FLAME_CHANNEL, FLAMEWARDEN_ROLE } from '@config/discord' -import { generateCustomId } from '@utils/component' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' import { log } from '@utils/logger' @@ -15,8 +13,6 @@ export class CheckinStatusError extends DiscordBaseError { } } -export const STATUS_LAST_CHECKIN_CLARIFICATION_BUTTON_ID = `${generateCustomId(COMMAND_PATH, __filename)}` - registerCommand({ data: new SlashCommandBuilder() .setName('checkin-status') diff --git a/src/bot/commands/checkin/messages/checkin-status.ts b/src/bot/commands/checkin/messages/checkin-status.ts index cb44bdd..b9ccd73 100644 --- a/src/bot/commands/checkin/messages/checkin-status.ts +++ b/src/bot/commands/checkin/messages/checkin-status.ts @@ -1,6 +1,6 @@ import type { Checkin } from '@type/checkin' import type { CheckinStreak } from '@type/checkin-streak' -import type { GuildMember } from 'discord.js' +import type { GuildMember, PublicThreadChannel } from 'discord.js' import { FLAMEWARDEN_ROLE, IGNITE_PATH_CHANNEL } from '@config/discord' import { getNow, getParsedNow } from '@utils/date' import { DiscordAssert } from '@utils/discord' @@ -13,9 +13,25 @@ export class CheckinStatusMessage extends DiscordAssert { static override readonly MSG = { ...DiscordAssert.MSG, + ThreadName: (publicId: string) => `❓ Klarifikasi Check-In #${publicId}`, + ThreadReason: (userTag: string) => `Check-in clarification requested by ${userTag}`, + ThreadContent: (checkin: Checkin) => ` +πŸ‘€ <@${checkin.user!.discord_id}> meminta klarifikasi untuk [*check-in*](${checkin.link!}) ini. +πŸ”₯ <@&${FLAMEWARDEN_ROLE}> mohon ditinjau. + +Teristimewa untuk <@&${FLAMEWARDEN_ROLE}>, silakan gunakan *command* **\`/checkin-audit\`** untuk melakukan *review* terhadap *check-in*. + `, + ThreadCreated: (thread: PublicThreadChannel) => ` +βœ… Sebuah thread klarifikasi telah dibuat: + +**${thread.name}** +πŸ”— [Lihat Thread](${thread.url}) + +Silakan gunakan thread ini untuk mendiskusikan detail *check-in* bersama <@&${FLAMEWARDEN_ROLE}>. + `, NoCheckin: (userDiscordId: string, checkinStreak: CheckinStreak | undefined) => ` Wahai Tuan/Nona <@${userDiscordId}>, -Nyala api Tuan/Nona belum dinyalakan hari ini. +nyala api Tuan/Nona belum dinyalakan hari ini. πŸ”₯ **Current Streak**: ${checkinStreak?.streak ?? 0} day(s) πŸ”Ž **Status**: Belum melakukan *check-in* > *"Percikan hari ini belum ditorehkan. Lakukan check-in sebelum 23:59 WIB, agar api Tuan/Nona tak meredup."* @@ -42,7 +58,7 @@ ${checkin.public_id} πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} day(s) πŸ”Ž **Status**: Disetujui; api Tuan/Nona kian terang πŸ—“ **Approved At**: ${getParsedNow(getNow(checkin.updated_at!))} -πŸ‘€ **Approved By**: ${flamewarden.displayName} (@${flamewarden.user.username}) +πŸ‘€ **Approved By**: <@${flamewarden.id}> ✍🏻 **${flamewarden.displayName}'(s) Comment**: ${checkin.comment ?? '-'} > *"[Nyala hari ini](${checkin.link}) diterima. Teruslah menenun aksara disiplin, satu hari demi satu hari."* `, @@ -56,13 +72,13 @@ ${checkin.public_id} πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} day(s) πŸ”Ž **Status**: Ditolak; percikan tak cukup kuat πŸ—“ **Reviewed At**: ${getParsedNow(getNow(checkin.updated_at!))} -πŸ‘€ **Reviewed By**: ${flamewarden.displayName} (@${flamewarden.user.username}) +πŸ‘€ **Reviewed By**: <@${flamewarden.id}> ✍🏻 **${flamewarden.displayName}'(s) Comment**: ${checkin.comment ?? '-'} > *"[Api Tuan/Nona](${checkin.link}) <@${userDiscordId}> meredup hari ini, namun belum padam sepenuhnya. Perbaiki, dan nyalakan kembali percikan yang benar."* `, LastCheckin: (userDiscordId: string, checkin: Checkin, flamewarden?: GuildMember) => ` Wahai Tuan/Nona <@${userDiscordId}>, -Tercatat bahwa rangkaian nyala api Tuan/Nona telah terputus pada pergantian hari sebelumnya. +tercatat bahwa rangkaian nyala api Tuan/Nona telah terputus pada pergantian hari sebelumnya. Namun demikian, percikan terakhir masih tersimpan dalam arsip Aksaria dan dapat ditinjau kembali. Berikut adalah *check-in* terakhir yang pernah Tuan/Nona torehkan: diff --git a/src/bot/commands/checkin/validators/checkin-status.ts b/src/bot/commands/checkin/validators/checkin-status.ts index 9404028..521320c 100644 --- a/src/bot/commands/checkin/validators/checkin-status.ts +++ b/src/bot/commands/checkin/validators/checkin-status.ts @@ -1,16 +1,17 @@ import type { PrismaClient } from '@generatedDB/client' -import type { Checkin as CheckinType } from '@type/checkin' +import type { CheckinStatusType, Checkin as CheckinType } from '@type/checkin' import type { User } from '@type/user' -import type { EmbedBuilder, Guild, Interaction } from 'discord.js' +import type { EmbedBuilder, Guild, Interaction, ThreadAutoArchiveDuration } from 'discord.js' import { CHECKIN_CHANNEL, FLAMEWARDEN_ROLE } from '@config/discord' -import { STATUS_LAST_CHECKIN_NOTE_BUTTON_ID } from '@events/interaction-create/checkin/handlers/status-last-checkin-note-button' +import { CHECKIN_STATUS_CLARIFICATION_BUTTON_ID } from '@events/interaction-create/checkin/handlers/status-clarification-button' +import { CHECKIN_STATUS_NOTE_BUTTON_ID } from '@events/interaction-create/checkin/handlers/status-note-button' import { Checkin } from '@events/interaction-create/checkin/validators' import { createEmbed, decodeSnowflakes, encodeSnowflake, getCustomId } from '@utils/component' import { isDateYesterday } from '@utils/date' import { DiscordAssert } from '@utils/discord' import { DUMMY } from '@utils/placeholder' import { ActionRowBuilder, ButtonBuilder, ButtonStyle, messageLink, PermissionsBitField } from 'discord.js' -import { CheckinStatusError, STATUS_LAST_CHECKIN_CLARIFICATION_BUTTON_ID } from '../handlers/checkin-status' +import { CheckinStatusError } from '../handlers/checkin-status' import { CheckinStatusMessage } from '../messages/checkin-status' export class CheckinStatus extends CheckinStatusMessage { @@ -19,6 +20,10 @@ export class CheckinStatus extends CheckinStatusMessage { PermissionsBitField.Flags.UseApplicationCommands, ] + static CLARIFICATION_EMOJI = '❓' + + static override THREAD_ARCHIVE_DURATION: ThreadAutoArchiveDuration = 1440 + static getButtonId(interaction: Interaction, customId: string) { const [prefix, guildId, checkinMessageId] = decodeSnowflakes(customId) @@ -44,7 +49,7 @@ export class CheckinStatus extends CheckinStatusMessage { if (checkin && hasCheckedInToday) { const flamewarden = await guild.members.fetch(checkin.reviewed_by!) - switch (checkin.status) { + switch (checkin.status as CheckinStatusType) { case 'WAITING': { content = `<@&${FLAMEWARDEN_ROLE}>` embed = createEmbed( @@ -108,13 +113,13 @@ export class CheckinStatus extends CheckinStatusMessage { if (checkin.status === 'WAITING') { const { messageId } = this.getMessageFromLink(checkin.link!) - const noteButtonId = getCustomId([STATUS_LAST_CHECKIN_NOTE_BUTTON_ID, encodeSnowflake(guildId), encodeSnowflake(messageId)]) + const noteButtonId = getCustomId([CHECKIN_STATUS_NOTE_BUTTON_ID, encodeSnowflake(guildId), encodeSnowflake(messageId)]) const noteButton = new ButtonBuilder() .setCustomId(noteButtonId) .setLabel('πŸ“œ Maklumat Klarifikasi') .setStyle(ButtonStyle.Primary) - const clarificationButtonId = getCustomId([STATUS_LAST_CHECKIN_CLARIFICATION_BUTTON_ID, encodeSnowflake(guildId), encodeSnowflake(messageId)]) + const clarificationButtonId = getCustomId([CHECKIN_STATUS_CLARIFICATION_BUTTON_ID, encodeSnowflake(guildId), encodeSnowflake(messageId)]) const clarificationButton = new ButtonBuilder() .setCustomId(clarificationButtonId) .setLabel('❓ Ajukan Klarifikasi') diff --git a/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts b/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts index 2e14aa4..782567e 100644 --- a/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts +++ b/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts @@ -1,9 +1,9 @@ -import type { Client } from 'discord.js' +import type { Client, TextChannel } from 'discord.js' import process from 'node:process' import { GRIND_ASHES_CHANNEL } from '@config/discord' import { registerClientReadyHandler } from '@events/client-ready/registry' import { EVENT_PATH } from '@events/index' -import { getChannel } from '@utils/discord' +import { getChannelOrThread } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' import { getModuleName } from '@utils/io' import { log } from '@utils/logger' @@ -27,7 +27,7 @@ registerClientReadyHandler({ log.check(ResetGrinderRoles.MSG.JobRunning) const guild = await client.guilds.fetch(process.env.GUILD_ID!) - const channel = await getChannel(guild, GRIND_ASHES_CHANNEL) + const channel = await getChannelOrThread(guild, GRIND_ASHES_CHANNEL) as TextChannel ResetGrinderRoles.assertChannel(channel) const users = await ResetGrinderRoles.getUsersWithLatestStreak(client.prisma) diff --git a/src/bot/events/guild-member-update/grinder-role/handlers/index.ts b/src/bot/events/guild-member-update/grinder-role/handlers/index.ts index 15e6bef..c4cc1ed 100644 --- a/src/bot/events/guild-member-update/grinder-role/handlers/index.ts +++ b/src/bot/events/guild-member-update/grinder-role/handlers/index.ts @@ -1,7 +1,8 @@ +import type { TextChannel } from 'discord.js' import { GRIND_ASHES_CHANNEL, GRINDER_ROLE } from '@config/discord' import { registerGuildMemberUpdateHandler } from '@events/guild-member-update/registry' import { EVENT_PATH } from '@events/index' -import { getChannel, sendAsBot } from '@utils/discord' +import { getChannelOrThread, sendAsBot } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' import { getModuleName } from '@utils/io' import { GrinderRole } from '../validators' @@ -26,7 +27,7 @@ registerGuildMemberUpdateHandler({ const newHasGrinderRole = GrinderRole.isMemberHasRole(newMember, GRINDER_ROLE) const oldHasGrinderRole = GrinderRole.isMemberHasRole(oldMember, GRINDER_ROLE) if (newHasGrinderRole && !oldHasGrinderRole) { - const channel = await getChannel(newMember.guild, GRIND_ASHES_CHANNEL) + const channel = await getChannelOrThread(newMember.guild, GRIND_ASHES_CHANNEL) as TextChannel GrinderRole.assertChannel(channel) const button = GrinderRole.generateButton(newMember.guild.id) diff --git a/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts b/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts index b1437ab..0e30fab 100644 --- a/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts +++ b/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts @@ -1,12 +1,13 @@ import type { CheckinStatusType } from '@type/checkin' -import type { TextChannel } from 'discord.js' +import type { ThreadChannel } from 'discord.js' import { FLAMEWARDEN_ROLE } from '@config/discord' import { EVENT_PATH } from '@events/index' import { registerInteractionHandler } from '@events/interaction-create/registry' -import { generateCustomId } from '@utils/component' +import { createEmbed, generateCustomId } from '@utils/component' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' import { getModuleName } from '@utils/io' +import { DUMMY } from '@utils/placeholder' import { Checkin } from '../validators' import { CheckinAudit } from '../validators/audit' @@ -35,8 +36,8 @@ registerInteractionHandler({ const { checkinId, checkinCreatedAt } = CheckinAudit.getModalReviewId(interaction, interaction.customId) - const channel = interaction.channel as TextChannel - CheckinAudit.assertMissPerms(interaction.client.user, channel) + const thread = interaction.channel as ThreadChannel + const threadMsg = await CheckinAudit.getThreadMessage(thread) const flamewarden = await interaction.guild.members.fetch(interaction.member.id) CheckinAudit.assertMember(flamewarden) CheckinAudit.assertMemberHasRole(flamewarden, FLAMEWARDEN_ROLE) @@ -55,7 +56,15 @@ registerInteractionHandler({ true, ) - await sendReply(interaction, CheckinAudit.MSG.AuditSuccess(updatedCheckin.link!, updatedCheckin.user!.discord_id)) + const embed = createEmbed( + `πŸ”₯ Audit Check-In Telah Diselesaikan`, + CheckinAudit.MSG.AuditSuccess(updatedCheckin.link!, flamewarden.id, updatedCheckin.user!.discord_id), + DUMMY.COLOR, + { text: DUMMY.FOOTER }, + ) + + await sendReply(interaction, '', false, { embeds: [embed], allowedMentions: { users: [updatedCheckin.user!.discord_id] } }) + await CheckinAudit.closeClarificationThread(thread, threadMsg) } catch (err: any) { if (err instanceof DiscordBaseError) diff --git a/src/bot/events/interaction-create/checkin/handlers/status-clarification-button.ts b/src/bot/events/interaction-create/checkin/handlers/status-clarification-button.ts new file mode 100644 index 0000000..4aef35c --- /dev/null +++ b/src/bot/events/interaction-create/checkin/handlers/status-clarification-button.ts @@ -0,0 +1,59 @@ +import type { CheckinStatusType } from '@type/checkin' +import type { TextChannel } from 'discord.js' +import { CheckinStatus } from '@commands/checkin/validators/checkin-status' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' + +export class CheckinStatusClarificationButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('CheckinStatusClarificationButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_STATUS_CLARIFICATION_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Creates a thread for the grinder to discuss check-in clarification with Flamewarden when the clarification button is clicked.', + id: CHECKIN_STATUS_CLARIFICATION_BUTTON_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, + async exec(client, interaction) { + if (!interaction.isButton()) + return + + try { + if (!interaction.inCachedGuild()) + throw new CheckinStatusClarificationButtonError(Checkin.ERR.NotGuild) + + const { checkinLink } = CheckinStatus.getButtonId(interaction, interaction.customId) + + const channel = interaction.channel as TextChannel + Checkin.assertMissPerms(interaction.client.user, channel) + + const checkin = await Checkin.getWaitingCheckin(client.prisma, 'link', checkinLink) + Checkin.assertWaitingCheckin(checkin.status as CheckinStatusType, checkin.link!) + Checkin.assertOwnedCheckin(checkin.user!.discord_id, interaction.user.id) + CheckinStatus.assertHasThread(interaction.message) + + const thread = await interaction.message.startThread({ + name: CheckinStatus.MSG.ThreadName(checkin.public_id), + reason: CheckinStatus.MSG.ThreadReason(interaction.user.tag), + autoArchiveDuration: CheckinStatus.THREAD_ARCHIVE_DURATION, + }) + + await thread.send({ content: CheckinStatus.MSG.ThreadContent(checkin) }) + await sendReply(interaction, CheckinStatus.MSG.ThreadCreated(thread)) + await interaction.message.react(CheckinStatus.CLARIFICATION_EMOJI) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/src/bot/events/interaction-create/checkin/handlers/status-last-checkin-note-button.ts b/src/bot/events/interaction-create/checkin/handlers/status-note-button.ts similarity index 83% rename from src/bot/events/interaction-create/checkin/handlers/status-last-checkin-note-button.ts rename to src/bot/events/interaction-create/checkin/handlers/status-note-button.ts index bcdb5f6..21550cc 100644 --- a/src/bot/events/interaction-create/checkin/handlers/status-last-checkin-note-button.ts +++ b/src/bot/events/interaction-create/checkin/handlers/status-note-button.ts @@ -9,18 +9,18 @@ import { getModuleName } from '@utils/io' import { messageLink } from 'discord.js' import { Checkin } from '../validators' -export class StatusLastCheckinButtonError extends DiscordBaseError { +export class CheckinStatusNoteButtonError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { - super('StatusLastCheckinButtonError', message, options) + super('CheckinStatusNoteButtonError', message, options) } } const moduleName = getModuleName(EVENT_PATH, __filename) -export const STATUS_LAST_CHECKIN_NOTE_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` +export const CHECKIN_STATUS_NOTE_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` registerInteractionHandler({ desc: 'Opens a note about how to request clarification for the last check-in if the streak was broken and did not reviewed.', - id: STATUS_LAST_CHECKIN_NOTE_BUTTON_ID, + id: CHECKIN_STATUS_NOTE_BUTTON_ID, errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, async exec(_, interaction) { if (!interaction.isButton()) @@ -28,7 +28,7 @@ registerInteractionHandler({ try { if (!interaction.inCachedGuild()) - throw new StatusLastCheckinButtonError(Checkin.ERR.NotGuild) + throw new CheckinStatusNoteButtonError(Checkin.ERR.NotGuild) const { checkinLink } = CheckinStatus.getButtonId(interaction, interaction.customId) diff --git a/src/bot/events/interaction-create/checkin/messages/audit.ts b/src/bot/events/interaction-create/checkin/messages/audit.ts index eecca18..0dbd9cd 100644 --- a/src/bot/events/interaction-create/checkin/messages/audit.ts +++ b/src/bot/events/interaction-create/checkin/messages/audit.ts @@ -1,4 +1,5 @@ import type { Checkin } from '@type/checkin' +import { getNow, getParsedNow } from '@utils/date' import { DiscordAssert } from '@utils/discord' export class CheckinAuditMessage extends DiscordAssert { @@ -9,11 +10,19 @@ export class CheckinAuditMessage extends DiscordAssert { ❌ Check-ins must be within 1 day of each other. Please validate [this check-in](${checkin.link!}) first: ${waitingCheckinList} `, + NotClarificationThread: '❌ This thread does not correspond to the correct check-in. Please make sure you are reviewing the correct clarification thread', UnexpectedCheckinAudit: '❌ Something went wrong during the check-in audit', } static override readonly MSG = { ...DiscordAssert.MSG, - AuditSuccess: (msgLink: string, userDiscordId: string) => `βœ… Successfully [audited check-in](${msgLink}) for <@${userDiscordId}>.`, + AuditSuccess: (checkinLink: string, flamewardenId: string, userDiscordId: string) => ` +Wahai Tuan/Nona <@${userDiscordId}>, +[percikan](${checkinLink}) yang Tuan/Nona titipkan telah selesai ditakar dan ditetapkan. +πŸ—“ **Audited At**: ${getParsedNow(getNow())} +πŸ‘€ **Audited By**: <@${flamewardenId}> + +> *"Api telah diuji, dan keputusannya kini tercatat dalam Aksaria."* + `, } } diff --git a/src/bot/events/interaction-create/checkin/messages/index.ts b/src/bot/events/interaction-create/checkin/messages/index.ts index d21f745..b491cf6 100644 --- a/src/bot/events/interaction-create/checkin/messages/index.ts +++ b/src/bot/events/interaction-create/checkin/messages/index.ts @@ -10,6 +10,8 @@ export class CheckinMessage extends DiscordAssert { ...DiscordAssert.ERR, AlreadyCheckinToday: (checkinMsgLink: string) => `❌ You already have a [check-in for today](${checkinMsgLink}). Please come back tomorrow`, SubmittedCheckinNotToday: (checkinMsgLink: string) => `❌ This [submitted check-in](${checkinMsgLink})'s date should equals as today. You can't review this anymore`, + NotWaitingCheckin: (checkinMsgLink: string) => `❌ This [check-in](${checkinMsgLink}) is no longer in a waiting state and cannot be processed further`, + NotYourCheckin: '❌ This is not your own check-in', UnknownCheckinStatus: '❌ The status for this check-in is unknown or unexpected', UnexpectedSubmittedCheckinMessage: '❌ Something went wrong while submitting your check-in', UnexpectedCheckin: '❌ Something went wrong during check-in', @@ -53,7 +55,7 @@ ${checkin.public_id} \`\`\` πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} πŸ—“ **Approved At**: ${getParsedNow(getNow(checkin.updated_at!))} -πŸ‘€ **Approved By**: ${flamewarden.displayName} (@${flamewarden.user.username}) +πŸ‘€ **Approved By**: <@${flamewarden.id}> ✍🏻 **${flamewarden.displayName}'(s) Comment**: ${checkin.comment ?? '-'} > πŸ”₯ Konsistensi ialah bahan bakar nyala api; teruskan langkah Tuan/Nona`, @@ -66,7 +68,7 @@ ${checkin.public_id} \`\`\` πŸ”₯ **Current Streak**: ${checkin.checkin_streak!.streak} πŸ—“ **Reviewed At**: ${getParsedNow(getNow(checkin.updated_at!))} -πŸ‘€ **Reviewed By**: ${flamewarden.displayName} (@${flamewarden.user.username}) +πŸ‘€ **Reviewed By**: <@${flamewarden.id}> ✍🏻 **${flamewarden.displayName}'(s) Comment**: ${checkin.comment ?? '-'} > 🧯 Nyala api Tuan/Nona meredup, namun belum padam; silakan mencuba kembali`, diff --git a/src/bot/events/interaction-create/checkin/validators/audit.ts b/src/bot/events/interaction-create/checkin/validators/audit.ts index 713916a..efd2367 100644 --- a/src/bot/events/interaction-create/checkin/validators/audit.ts +++ b/src/bot/events/interaction-create/checkin/validators/audit.ts @@ -1,8 +1,9 @@ import type { PrismaClient } from '@generatedDB/client' import type { Checkin as CheckinType } from '@type/checkin' import type { CheckinStreak } from '@type/checkin-streak' -import type { Interaction } from 'discord.js' +import type { Interaction, Message, ThreadChannel } from 'discord.js' import { CheckinAuditError } from '@commands/checkin/handlers/checkin-audit' +import { CheckinStatus } from '@commands/checkin/validators/checkin-status' import { decodeSnowflakes } from '@utils/component' import { isDateToday } from '@utils/date' import { DiscordAssert } from '@utils/discord' @@ -36,6 +37,15 @@ export class CheckinAudit extends CheckinAuditMessage { return { prefix, guildId, checkinId, checkinCreatedAt } } + static assertClarificationThread(thread: ThreadChannel, checkinPublicId: string) { + const threadName = thread.name + const checkinThreadName = CheckinStatus.MSG.ThreadName(checkinPublicId) + + if (threadName !== checkinThreadName) { + throw new CheckinAuditError(this.ERR.NotClarificationThread) + } + } + static assertCheckinNotToday(checkin: CheckinType) { if (isDateToday(checkin.created_at)) { throw new CheckinAuditError(CheckinAudit.ERR.CheckinShouldNotToday(checkin.link!)) @@ -104,4 +114,10 @@ ${checkin.public_id} return checkinStreak.checkins! } + + static async closeClarificationThread(thread: ThreadChannel, threadMessage: Message) { + await threadMessage.react('πŸ”₯') + await thread.setLocked(true) + await thread.setArchived(true) + } } diff --git a/src/bot/events/interaction-create/checkin/validators/index.ts b/src/bot/events/interaction-create/checkin/validators/index.ts index 25d85b1..3d983ba 100644 --- a/src/bot/events/interaction-create/checkin/validators/index.ts +++ b/src/bot/events/interaction-create/checkin/validators/index.ts @@ -11,7 +11,7 @@ import { AURA_FARMING_CHANNEL, CHECKIN_CHANNEL, GRINDER_ROLE } from '@config/dis import { SubmittedCheckinError } from '@events/message-reaction-add/checkin/handlers/submitted' import { createEmbed, decodeSnowflakes, encodeSnowflake, getCustomId } from '@utils/component' import { isDateToday, isDateYesterday } from '@utils/date' -import { DiscordAssert, getChannel, sendAsBot } from '@utils/discord' +import { DiscordAssert, getChannelOrThread, sendAsBot } from '@utils/discord' import { attachNewGrindRole, getGrindRoleByStreakCount } from '@utils/discord/roles' import { DUMMY } from '@utils/placeholder' import { ActionRowBuilder, ButtonBuilder, ButtonStyle, messageLink, PermissionsBitField } from 'discord.js' @@ -155,7 +155,7 @@ export class Checkin extends CheckinMessage { return const hasGrindRole = this.isMemberHasRole(member, newRole.id) - const channel = await getChannel(guild, AURA_FARMING_CHANNEL) + const channel = await getChannelOrThread(guild, AURA_FARMING_CHANNEL) as TextChannel this.assertChannel(channel) if (!hasGrindRole) { @@ -166,7 +166,7 @@ export class Checkin extends CheckinMessage { }) } else { - const checkinChannel = await getChannel(guild, CHECKIN_CHANNEL) + const checkinChannel = await getChannelOrThread(guild, CHECKIN_CHANNEL) as TextChannel await sendAsBot(null, checkinChannel, { content: `Hey, <@${member.id}>. You already have <@&${newRole.id}>`, allowedMentions: { users: [member.id], roles: [] }, @@ -208,6 +208,18 @@ export class Checkin extends CheckinMessage { return emoji as CheckinAllowedEmojiType } + static assertWaitingCheckin(checkinStatus: CheckinStatusType, checkinMsgLink: string) { + if (checkinStatus !== 'WAITING') { + throw new SubmittedCheckinError(this.ERR.NotWaitingCheckin(checkinMsgLink)) + } + } + + static assertOwnedCheckin(checkinUserDiscordId: string, currentUserId: string) { + if (checkinUserDiscordId !== currentUserId) { + throw new SubmittedCheckinError(this.ERR.NotYourCheckin) + } + } + static async getOrCreateUser(prisma: PrismaClient, userDiscordId: string): Promise { const select = { id: true, diff --git a/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts b/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts index 6dd72d8..4a1f719 100644 --- a/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts +++ b/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts @@ -1,7 +1,8 @@ +import type { TextChannel } from 'discord.js' import { EVENT_PATH } from '@events/index' import { registerInteractionHandler } from '@events/interaction-create/registry' import { createEmbed, encodeSnowflake, generateCustomId, getCustomId } from '@utils/component' -import { getChannel, getRole, sendAsBot, sendReply } from '@utils/discord' +import { getChannelOrThread, getRole, sendAsBot, sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' import { getModuleName } from '@utils/io' import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js' @@ -30,7 +31,7 @@ registerInteractionHandler({ throw new EmbedRoleGrantModalError(RoleGrantCreate.ERR.NotGuild) const { channelId, roleId, buttonName } = RoleGrantCreate.getModalId(interaction, interaction.customId) - const channel = await getChannel(interaction.guild, channelId) + const channel = await getChannelOrThread(interaction.guild, channelId) as TextChannel RoleGrantCreate.assertChannel(channel) RoleGrantCreate.assertMissPerms(interaction.client.user, channel) const role = await getRole(interaction.guild, roleId) diff --git a/src/bot/events/interaction-create/message/handlers/send-modal.ts b/src/bot/events/interaction-create/message/handlers/send-modal.ts index 8fe3f81..2f66cae 100644 --- a/src/bot/events/interaction-create/message/handlers/send-modal.ts +++ b/src/bot/events/interaction-create/message/handlers/send-modal.ts @@ -1,8 +1,8 @@ -import type { Attachment } from 'discord.js' +import type { Attachment, TextChannel } from 'discord.js' import { EVENT_PATH } from '@events/index' import { registerInteractionHandler } from '@events/interaction-create/registry' import { generateCustomId, tempStore } from '@utils/component' -import { getChannel, sendAsBot, sendReply } from '@utils/discord' +import { getChannelOrThread, sendAsBot, sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' import { getModuleName } from '@utils/io' import { Send } from '../validators/send' @@ -29,7 +29,7 @@ registerInteractionHandler({ throw new SendModalError(Send.ERR.NotGuild) const { channelId, tempToken } = Send.getModalId(interaction, interaction.customId) - const channel = await getChannel(interaction.guild, channelId) + const channel = await getChannelOrThread(interaction.guild, channelId) as TextChannel Send.assertChannel(channel) Send.assertMissPerms(interaction.client.user, channel) const attachments = tempStore.get(tempToken) as Attachment[] diff --git a/src/utils/discord/assert.ts b/src/utils/discord/assert.ts index d434dc9..105be25 100644 --- a/src/utils/discord/assert.ts +++ b/src/utils/discord/assert.ts @@ -1,7 +1,7 @@ -import type { ClientUser, Guild, GuildMember, Role, TextChannel } from 'discord.js' +import type { ClientUser, Guild, GuildMember, Message, Role, TextChannel, ThreadAutoArchiveDuration, ThreadChannel } from 'discord.js' import { getTempToken, parseMessageLink, tempStore } from '@utils/component' import { ChannelType, PermissionsBitField } from 'discord.js' -import { getBotPerms, getChannel, getMissPerms } from '.' +import { getBotPerms, getChannelOrThread, getMissPerms } from '.' import { DiscordBaseError } from './error' import { DiscordMessage } from './message' @@ -29,6 +29,7 @@ export class DiscordAssert extends DiscordMessage { ) static ATTACHMENT_COUNT = 10 + static THREAD_ARCHIVE_DURATION: ThreadAutoArchiveDuration = 1440 static getMessageFromLink(link: string) { const data = parseMessageLink(link) @@ -39,6 +40,15 @@ export class DiscordAssert extends DiscordMessage { return { ...data } } + static async getThreadMessage(thread: ThreadChannel) { + const starterMessage = await thread.fetchStarterMessage() + if (!starterMessage) { + throw new DiscordAssertError(this.ERR.FailedToFetchThreadFirstMessage) + } + + return starterMessage + } + static setTempItem(items: any): string { const token = getTempToken() tempStore.set(token, items) @@ -92,7 +102,7 @@ export class DiscordAssert extends DiscordMessage { throw new DiscordAssertError(this.ERR.AllowedChannel(channelId)) } - const channel = await getChannel(guild, channelId) + const channel = await getChannelOrThread(guild, channelId) as TextChannel this.assertChannel(channel) return channel @@ -109,6 +119,42 @@ export class DiscordAssert extends DiscordMessage { } } + static assertHasThread(message: Message) { + if (message.hasThread && message.hasThread) { + throw new DiscordAssertError(this.ERR.ChannelAlreadyHasThread) + } + } + + static async assertThreadUnderChannel(guild: Guild, currentChannelId: string, parentChannel: TextChannel) { + const thread = await getChannelOrThread(guild, currentChannelId) as ThreadChannel + + if (!thread.isThread()) + throw new DiscordAssertError(this.ERR.MustBeThread(parentChannel.id)) + + if ('parentId' in thread && thread.parentId !== parentChannel.id) + throw new DiscordAssertError(this.ERR.MustBeThread(parentChannel.id)) + + return thread + } + + static assertNotArchivedThread(thread: ThreadChannel) { + if (thread.archived) { + throw new DiscordAssertError(this.ERR.ArchivedThread) + } + } + + static assertNotPrivateThread(thread: ThreadChannel) { + if (thread.type === ChannelType.PrivateThread) { + throw new DiscordAssertError(this.ERR.PrivateThread) + } + } + + static assertThreadMessageSendBy(threadMessage: Message, userId: string) { + if (threadMessage.author.id !== userId) { + throw new DiscordAssertError(this.ERR.ThreadMessageShouldSendBy(userId)) + } + } + static isMemberHasRole(member: GuildMember, roleId: string): boolean { return member.roles.cache.has(roleId) } diff --git a/src/utils/discord/index.ts b/src/utils/discord/index.ts index 0508d71..7877fc1 100644 --- a/src/utils/discord/index.ts +++ b/src/utils/discord/index.ts @@ -1,8 +1,13 @@ -import type { Attachment, ChatInputCommandInteraction, ClientUser, Guild, GuildMember, Interaction, InteractionDeferReplyOptions, InteractionReplyOptions, MessageCreateOptions, PermissionsBitField, Role, TextChannel } from 'discord.js' +import type { Attachment, ChatInputCommandInteraction, ClientUser, Guild, GuildMember, Interaction, InteractionDeferReplyOptions, InteractionReplyOptions, MessageCreateOptions, PermissionsBitField, Role, TextChannel, ThreadChannel } from 'discord.js' import { MessageFlags } from 'discord.js' -export async function getChannel(guild: Guild, id: string): Promise { - return guild!.channels.cache.get(id) as TextChannel ?? await guild!.channels.fetch(id).then(channel => channel as TextChannel) +export async function getChannelOrThread(guild: Guild, id: string, isThread: boolean = false): Promise { + if (isThread) { + return guild!.channels.cache.get(id) as TextChannel ?? await guild!.channels.fetch(id).then(channel => channel as TextChannel) + } + else { + return guild!.channels.cache.get(id) as ThreadChannel ?? await guild!.channels.fetch(id).then(channel => channel as ThreadChannel) + } } export async function getRole(guild: Guild, id: string): Promise { diff --git a/src/utils/discord/message.ts b/src/utils/discord/message.ts index ac35589..943123a 100644 --- a/src/utils/discord/message.ts +++ b/src/utils/discord/message.ts @@ -21,6 +21,12 @@ export class DiscordMessage { CannotPost: '❌ I can’t post in that channel', MessageIdMissing: '❌ Message ID is missing or invalid', MessageLinkInvalid: '❌ The provided message link is invalid', + ChannelAlreadyHasThread: '❌ This channel message already has an associated thread', + MustBeThread: (parentChannelId: string) => `❌ This action can only be performed in a thread under <#${parentChannelId}>`, + ArchivedThread: '❌ This thread is archived', + PrivateThread: '❌ This action cannot be performed in a private thread', + FailedToFetchThreadFirstMessage: '❌ Failed to fetch the first message in this thread', + ThreadMessageShouldSendBy: (userId: string) => `❌ The first thread message must be sent by <@${userId}> to perform this action`, PlainMessage: '❌ There is nothing to do with this plain message', CheckinIdMissing: '❌ Check-in ID is missing or invalid',