diff --git a/src/bot/commands/checkin/handlers/checkin-status.ts b/src/bot/commands/checkin/handlers/checkin-status.ts index 909a5c2..a119d10 100644 --- a/src/bot/commands/checkin/handlers/checkin-status.ts +++ b/src/bot/commands/checkin/handlers/checkin-status.ts @@ -1,6 +1,8 @@ -import type { ChatInputCommandInteraction, Client, GuildMember } from 'discord.js' +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, GRINDER_ROLE } from '@config/discord' +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' @@ -13,6 +15,8 @@ export class CheckinStatusError extends DiscordBaseError { } } +export const STATUS_LAST_CHECKIN_CLARIFICATION_BUTTON_ID = `${generateCustomId(COMMAND_PATH, __filename)}` + registerCommand({ data: new SlashCommandBuilder() .setName('checkin-status') @@ -25,21 +29,23 @@ registerCommand({ const channel = await CheckinStatus.assertAllowedChannel(interaction.guild, interaction.channelId, AUDIT_FLAME_CHANNEL) CheckinStatus.assertMissPerms(interaction.client.user, channel) + const member = interaction.member as GuildMember + CheckinStatus.assertMember(member) const userDiscordId: string = interaction.user.id - const member = interaction.member as GuildMember const user = await CheckinStatus.getUser(client.prisma, userDiscordId) - CheckinStatus.assertMember(member) - CheckinStatus.assertMemberHasRole(member, GRINDER_ROLE) - - const { content, embed } = await CheckinStatus.getEmbedStatusContent( + const { content, embed, buttons } = await CheckinStatus.getEmbedStatusContent( interaction.guild, user?.discord_id ?? member.id, user?.checkins?.[0], ) - await sendReply(interaction, content, false, { embeds: [embed], allowedMentions: { roles: [FLAMEWARDEN_ROLE] } }) + const payloads = { embeds: [embed], allowedMentions: { roles: [FLAMEWARDEN_ROLE] } } as InteractionReplyOptions + if (buttons) + payloads.components = [buttons] + + await sendReply(interaction, content, false, payloads) } catch (err: any) { if (err instanceof DiscordBaseError) diff --git a/src/bot/commands/checkin/messages/checkin-status.ts b/src/bot/commands/checkin/messages/checkin-status.ts index 790245d..cb44bdd 100644 --- a/src/bot/commands/checkin/messages/checkin-status.ts +++ b/src/bot/commands/checkin/messages/checkin-status.ts @@ -1,7 +1,7 @@ import type { Checkin } from '@type/checkin' import type { CheckinStreak } from '@type/checkin-streak' import type { GuildMember } from 'discord.js' -import { FLAMEWARDEN_ROLE } from '@config/discord' +import { FLAMEWARDEN_ROLE, IGNITE_PATH_CHANNEL } from '@config/discord' import { getNow, getParsedNow } from '@utils/date' import { DiscordAssert } from '@utils/discord' @@ -16,7 +16,6 @@ export class CheckinStatusMessage extends DiscordAssert { NoCheckin: (userDiscordId: string, checkinStreak: CheckinStreak | undefined) => ` Wahai Tuan/Nona <@${userDiscordId}>, Nyala api Tuan/Nona belum dinyalakan hari ini. -🗓 **Date**: ${getParsedNow()} 🔥 **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."* @@ -55,11 +54,45 @@ ${checkin.public_id} 🌟 **Grinder**: <@${userDiscordId}> 📁 **Attachment:** ${checkin.attachments?.length ? '✅' : '❌'} 🔥 **Current Streak**: ${checkin.checkin_streak!.streak} day(s) -🔎 **Status**: Disetujui; api Tuan/Nona kian terang +🔎 **Status**: Ditolak; percikan tak cukup kuat 🗓 **Reviewed At**: ${getParsedNow(getNow(checkin.updated_at!))} 👀 **Reviewed By**: ${flamewarden.displayName} (@${flamewarden.user.username}) ✍🏻 **${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. +Namun demikian, percikan terakhir masih tersimpan dalam arsip Aksaria dan dapat ditinjau kembali. + +Berikut adalah *check-in* terakhir yang pernah Tuan/Nona torehkan: +🆔 **Check-In ID**: +\`\`\`bash +${checkin.public_id} +\`\`\` +🌟 **Grinder**: <@${userDiscordId}> +📁 **Attachment:** ${checkin.attachments?.length ? '✅' : '❌'} +🗓 **Submitted At**: ${getParsedNow(getNow(checkin.created_at))} +🔥 **Last Streak**: ${checkin.checkin_streak!.streak} day(s) +💥 **Broken Streak**: ${checkin.checkin_streak!.streak_broken_at ? '✅' : '❌'} +🔎 **Status**: ${checkin.status} +${flamewarden?.displayName + ? `🗓 **Reviewed At**: ${getParsedNow(getNow(checkin.updated_at!))} +👀 **Reviewed By**: ${flamewarden.displayName} (@${flamewarden.user.username}) +✍🏻 **${flamewarden.displayName}'(s) Comment**: ${checkin.comment ?? '-'}` + : ''} +> *"[Percikan ini](${checkin.link}) pernah kau titipkan pada api, namun belum sempat ditakar oleh penjaga nyala."* + `, + LastCheckinNote: (checkinLink: string, statusLink: string) => ` +Apabila Tuan/Nona meyakini bahwa [*check-in*](${checkinLink}) belum sempat ditinjau oleh <@&${FLAMEWARDEN_ROLE}>, +maka Aksaria membuka ruang klarifikasi dengan tata cara sebagai berikut: +Ⅰ. Berikan reaksi ❓ pada pesan [*status check-in*](${statusLink}) ini. +Ⅱ. Sebuah *thread* khusus akan tercipta secara otomatis. +Ⅲ. Gunakan *thread* tersebut untuk berkomunikasi dan mengajukan peninjauan kepada <@&${FLAMEWARDEN_ROLE}>. + +⚠️ Ketentuan Penting: +Selama proses klarifikasi berlangsung, Tuan/Nona tidak diperkenankan terlebih dahulu memasuki <#${IGNITE_PATH_CHANNEL}>, demi menjaga ketertiban alur peninjauan. +Waktu klarifikasi dibuka maksimal 1x24 jam sejak *check-in* diajukan. + `, } } diff --git a/src/bot/commands/checkin/validators/checkin-status.ts b/src/bot/commands/checkin/validators/checkin-status.ts index f77da52..9404028 100644 --- a/src/bot/commands/checkin/validators/checkin-status.ts +++ b/src/bot/commands/checkin/validators/checkin-status.ts @@ -1,13 +1,16 @@ import type { PrismaClient } from '@generatedDB/client' import type { Checkin as CheckinType } from '@type/checkin' import type { User } from '@type/user' -import type { EmbedBuilder, Guild } from 'discord.js' -import { FLAMEWARDEN_ROLE } from '@config/discord' +import type { EmbedBuilder, Guild, Interaction } 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 } from '@events/interaction-create/checkin/validators' -import { createEmbed } from '@utils/component' +import { createEmbed, decodeSnowflakes, encodeSnowflake, getCustomId } from '@utils/component' +import { isDateYesterday } from '@utils/date' import { DiscordAssert } from '@utils/discord' import { DUMMY } from '@utils/placeholder' -import { PermissionsBitField } from 'discord.js' +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, messageLink, PermissionsBitField } from 'discord.js' +import { CheckinStatusError, STATUS_LAST_CHECKIN_CLARIFICATION_BUTTON_ID } from '../handlers/checkin-status' import { CheckinStatusMessage } from '../messages/checkin-status' export class CheckinStatus extends CheckinStatusMessage { @@ -16,17 +19,33 @@ export class CheckinStatus extends CheckinStatusMessage { PermissionsBitField.Flags.UseApplicationCommands, ] - static async getEmbedStatusContent(guild: Guild, userDiscordId: string, checkin: CheckinType | undefined) { + static getButtonId(interaction: Interaction, customId: string) { + const [prefix, guildId, checkinMessageId] = decodeSnowflakes(customId) + + if (!guildId) + throw new CheckinStatusError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new CheckinStatusError(this.ERR.NotGuild) + if (!checkinMessageId) + throw new CheckinStatusError(this.ERR.CheckinIdMissing) + + const checkinLink = messageLink(CHECKIN_CHANNEL, checkinMessageId, interaction.guildId) + + return { prefix, guildId, checkinLink } + } + + static async getEmbedStatusContent(guild: Guild, userDiscordId: string, checkin?: CheckinType) { let content = '' let embed: EmbedBuilder - const checkinStreak = checkin?.checkin_streak + const checkinStreak = checkin?.checkin_streak const hasCheckedInToday = Checkin.hasCheckinToday(checkinStreak, checkin) - if (hasCheckedInToday && checkin) { + + if (checkin && hasCheckedInToday) { const flamewarden = await guild.members.fetch(checkin.reviewed_by!) switch (checkin.status) { - case 'WAITING': + case 'WAITING': { content = `<@&${FLAMEWARDEN_ROLE}>` embed = createEmbed( `🧭 Check-In #${checkin.public_id}`, @@ -35,8 +54,9 @@ export class CheckinStatus extends CheckinStatusMessage { { text: DUMMY.FOOTER }, ) break + } - case 'APPROVED': + case 'APPROVED': { embed = createEmbed( `🔥 Check-In #${checkin.public_id}`, CheckinStatus.MSG.ApprovedCheckin(userDiscordId, flamewarden, checkin), @@ -44,8 +64,9 @@ export class CheckinStatus extends CheckinStatusMessage { { text: DUMMY.FOOTER }, ) break + } - default: + default: { embed = createEmbed( `❌ Check-In #${checkin.public_id}`, CheckinStatus.MSG.RejectedCheckin(userDiscordId, flamewarden, checkin), @@ -53,18 +74,54 @@ export class CheckinStatus extends CheckinStatusMessage { { text: DUMMY.FOOTER }, ) break + } } + + return { content, embed } } - else { + + const shouldShowNoCheckin = !checkin || (checkin.status === 'APPROVED' && isDateYesterday(checkin.created_at)) + if (shouldShowNoCheckin) { embed = createEmbed( `🧐 Check-In`, CheckinStatus.MSG.NoCheckin(userDiscordId, checkinStreak), DUMMY.COLOR, { text: DUMMY.FOOTER }, ) + + return { content, embed } } - return { content, embed } + const flamewarden = await guild.members.fetch(checkin.reviewed_by!) + const buttons = this.generateButtons(guild.id, checkin) + embed = createEmbed( + `🕯️ Check-In #${checkin.public_id}`, + CheckinStatus.MSG.LastCheckin(userDiscordId, checkin, flamewarden), + DUMMY.COLOR, + { text: DUMMY.FOOTER }, + ) + + return { content, embed, buttons } + } + + static generateButtons(guildId: string, checkin: CheckinType): ActionRowBuilder | undefined { + if (checkin.status === 'WAITING') { + const { messageId } = this.getMessageFromLink(checkin.link!) + + const noteButtonId = getCustomId([STATUS_LAST_CHECKIN_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 clarificationButton = new ButtonBuilder() + .setCustomId(clarificationButtonId) + .setLabel('❓ Ajukan Klarifikasi') + .setStyle(ButtonStyle.Success) + + return new ActionRowBuilder().addComponents(noteButton, clarificationButton) + } } static async getUser(prisma: PrismaClient, userDiscordId: string): Promise { diff --git a/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts b/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts index 4ec6821..21ec8b4 100644 --- a/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts +++ b/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts @@ -25,10 +25,10 @@ Namun jangan berduka, jalan ini selalu terbuka bagi mereka yang bersedia memulai `, GoodByeNotes: ` > Apabila *check-in* Tuan/Nona masih berada dalam status menunggu peninjauan (*waiting*) dan belum memperoleh keputusan hingga mendekati pergantian hari, maka dengan ini disampaikan ketentuan berikut: -> 1. Jangan terlebih dahulu memasuki ⁠<#${IGNITE_PATH_CHANNEL}>, demi menjaga ketertiban alur peninjauan. -> 2. Silakan menjalankan perintah **\`/checkin-status\`** pada <#${AUDIT_FLAME_CHANNEL}> untuk menampilkan status *check-in* terakhir Tuan/Nona. -> 3. Setelah pesan status tersebut muncul, berikan reaksi "❓" pada pesan tersebut. -> 4. Dari reaksi tersebut, sebuah *thread* akan tercipta secara otomatis sebagai ruang klarifikasi dan komunikasi dengan <@&${FLAMEWARDEN_ROLE}>. +> Ⅰ. Jangan terlebih dahulu memasuki ⁠<#${IGNITE_PATH_CHANNEL}>, demi menjaga ketertiban alur peninjauan. +> Ⅱ. Silakan menjalankan perintah **\`/checkin-status\`** pada <#${AUDIT_FLAME_CHANNEL}> untuk menampilkan status *check-in* terakhir Tuan/Nona. +> Ⅲ. Setelah pesan status tersebut muncul, berikan reaksi "❓" pada pesan tersebut. +> Ⅳ. Dari reaksi tersebut, sebuah *thread* akan tercipta secara otomatis sebagai ruang klarifikasi dan komunikasi dengan <@&${FLAMEWARDEN_ROLE}>. > ⏳ Batas waktu penantian atas status *WAITING* adalah maksimal 1×24 jam sejak *check-in* diajukan. `, } 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-last-checkin-note-button.ts new file mode 100644 index 0000000..bcdb5f6 --- /dev/null +++ b/src/bot/events/interaction-create/checkin/handlers/status-last-checkin-note-button.ts @@ -0,0 +1,48 @@ +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 { messageLink } from 'discord.js' +import { Checkin } from '../validators' + +export class StatusLastCheckinButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('StatusLastCheckinButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const STATUS_LAST_CHECKIN_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, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, + async exec(_, interaction) { + if (!interaction.isButton()) + return + + try { + if (!interaction.inCachedGuild()) + throw new StatusLastCheckinButtonError(Checkin.ERR.NotGuild) + + const { checkinLink } = CheckinStatus.getButtonId(interaction, interaction.customId) + + const channel = interaction.channel as TextChannel + Checkin.assertMissPerms(interaction.client.user, channel) + + const statusMessageLink = messageLink(interaction.channelId, interaction.message.id, interaction.guildId) + + await sendReply(interaction, CheckinStatus.MSG.LastCheckinNote(checkinLink, statusMessageLink)) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +})