From ae02a487d7bc6d6ada76d08d8964cdb4a229d683 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 16:08:46 +0700 Subject: [PATCH 01/18] chore: rename into `status-note-button` --- src/bot/commands/checkin/validators/checkin-status.ts | 2 +- ...status-last-checkin-note-button.ts => status-note-button.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/bot/events/interaction-create/checkin/handlers/{status-last-checkin-note-button.ts => status-note-button.ts} (100%) diff --git a/src/bot/commands/checkin/validators/checkin-status.ts b/src/bot/commands/checkin/validators/checkin-status.ts index 9404028..3eca014 100644 --- a/src/bot/commands/checkin/validators/checkin-status.ts +++ b/src/bot/commands/checkin/validators/checkin-status.ts @@ -3,7 +3,7 @@ import type { Checkin as CheckinType } from '@type/checkin' import type { User } from '@type/user' 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 { STATUS_LAST_CHECKIN_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' 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 100% 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 From 40a0a6c8d15914b56d720194731c2d618693473d Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 16:11:52 +0700 Subject: [PATCH 02/18] chore: variable namings --- src/bot/commands/checkin/validators/checkin-status.ts | 4 ++-- .../checkin/handlers/status-note-button.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/bot/commands/checkin/validators/checkin-status.ts b/src/bot/commands/checkin/validators/checkin-status.ts index 3eca014..084b9ff 100644 --- a/src/bot/commands/checkin/validators/checkin-status.ts +++ b/src/bot/commands/checkin/validators/checkin-status.ts @@ -3,7 +3,7 @@ import type { Checkin as CheckinType } from '@type/checkin' import type { User } from '@type/user' 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-note-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' @@ -108,7 +108,7 @@ 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') diff --git a/src/bot/events/interaction-create/checkin/handlers/status-note-button.ts b/src/bot/events/interaction-create/checkin/handlers/status-note-button.ts index bcdb5f6..21550cc 100644 --- a/src/bot/events/interaction-create/checkin/handlers/status-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) From 74415461502e73862b0e428b15fbbce50bb52ee6 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 17:33:17 +0700 Subject: [PATCH 03/18] feat: base thread archive duration --- src/utils/discord/assert.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/discord/assert.ts b/src/utils/discord/assert.ts index d434dc9..4419fc6 100644 --- a/src/utils/discord/assert.ts +++ b/src/utils/discord/assert.ts @@ -1,4 +1,4 @@ -import type { ClientUser, Guild, GuildMember, Role, TextChannel } from 'discord.js' +import type { ClientUser, Guild, GuildMember, Role, TextChannel, ThreadAutoArchiveDuration } from 'discord.js' import { getTempToken, parseMessageLink, tempStore } from '@utils/component' import { ChannelType, PermissionsBitField } from 'discord.js' import { getBotPerms, getChannel, getMissPerms } from '.' @@ -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) From 514640604e24cace7366b7e0ed2541c1e031b9a0 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 17:34:01 +0700 Subject: [PATCH 04/18] feat: waiting and owned checkin assert/message --- .../interaction-create/checkin/messages/index.ts | 2 ++ .../interaction-create/checkin/validators/index.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/bot/events/interaction-create/checkin/messages/index.ts b/src/bot/events/interaction-create/checkin/messages/index.ts index d21f745..27b68c6 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', diff --git a/src/bot/events/interaction-create/checkin/validators/index.ts b/src/bot/events/interaction-create/checkin/validators/index.ts index 25d85b1..79cdae5 100644 --- a/src/bot/events/interaction-create/checkin/validators/index.ts +++ b/src/bot/events/interaction-create/checkin/validators/index.ts @@ -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, From a3b3555877ef8f4ebb4a326b75eae5e20f7d710d Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 17:36:18 +0700 Subject: [PATCH 05/18] feat: thread assertion --- src/utils/discord/assert.ts | 8 +++++++- src/utils/discord/message.ts | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/utils/discord/assert.ts b/src/utils/discord/assert.ts index 4419fc6..ee3c213 100644 --- a/src/utils/discord/assert.ts +++ b/src/utils/discord/assert.ts @@ -1,4 +1,4 @@ -import type { ClientUser, Guild, GuildMember, Role, TextChannel, ThreadAutoArchiveDuration } from 'discord.js' +import type { ClientUser, Guild, GuildMember, Message, Role, TextChannel, ThreadAutoArchiveDuration } from 'discord.js' import { getTempToken, parseMessageLink, tempStore } from '@utils/component' import { ChannelType, PermissionsBitField } from 'discord.js' import { getBotPerms, getChannel, getMissPerms } from '.' @@ -110,6 +110,12 @@ export class DiscordAssert extends DiscordMessage { } } + static assertHasThread(message: Message) { + if (message.hasThread && message.hasThread) { + throw new DiscordAssertError(this.ERR.ChannelAlreadyHasThread) + } + } + static isMemberHasRole(member: GuildMember, roleId: string): boolean { return member.roles.cache.has(roleId) } diff --git a/src/utils/discord/message.ts b/src/utils/discord/message.ts index ac35589..ec2c10c 100644 --- a/src/utils/discord/message.ts +++ b/src/utils/discord/message.ts @@ -21,6 +21,7 @@ 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', PlainMessage: '❌ There is nothing to do with this plain message', CheckinIdMissing: '❌ Check-in ID is missing or invalid', From 602c24b3c08c05bfedbf8f167ed97daf7c533ae6 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 17:37:42 +0700 Subject: [PATCH 06/18] chore: typography --- src/bot/events/interaction-create/checkin/messages/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot/events/interaction-create/checkin/messages/index.ts b/src/bot/events/interaction-create/checkin/messages/index.ts index 27b68c6..3d71186 100644 --- a/src/bot/events/interaction-create/checkin/messages/index.ts +++ b/src/bot/events/interaction-create/checkin/messages/index.ts @@ -11,7 +11,7 @@ export class CheckinMessage extends DiscordAssert { 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.', + 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', From 20a635e89b3b5cb97c4479b20607aa9dca281d3a Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 17:38:22 +0700 Subject: [PATCH 07/18] feat: checkin status clarification button handler --- .../checkin/handlers/checkin-status.ts | 4 -- .../checkin/messages/checkin-status.ts | 16 ++++- .../checkin/validators/checkin-status.ts | 15 +++-- .../handlers/status-clarification-button.ts | 59 +++++++++++++++++++ 4 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 src/bot/events/interaction-create/checkin/handlers/status-clarification-button.ts 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..9d9e6ad 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,6 +13,20 @@ 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. + `, + 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. diff --git a/src/bot/commands/checkin/validators/checkin-status.ts b/src/bot/commands/checkin/validators/checkin-status.ts index 084b9ff..521320c 100644 --- a/src/bot/commands/checkin/validators/checkin-status.ts +++ b/src/bot/commands/checkin/validators/checkin-status.ts @@ -1,8 +1,9 @@ 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 { 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' @@ -10,7 +11,7 @@ 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( @@ -114,7 +119,7 @@ export class CheckinStatus extends CheckinStatusMessage { .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/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..49f3f14 --- /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)) + interaction.message.react(CheckinStatus.CLARIFICATION_EMOJI) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) From 3181309ad7a841a469d8274476cd76fde8c293ef Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 18:31:45 +0700 Subject: [PATCH 08/18] feat: thread assertions --- .../checkin/messages/audit.ts | 3 +- .../checkin/validators/audit.ts | 12 +++++- src/utils/discord/assert.ts | 43 ++++++++++++++++++- src/utils/discord/message.ts | 5 +++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/bot/events/interaction-create/checkin/messages/audit.ts b/src/bot/events/interaction-create/checkin/messages/audit.ts index eecca18..700c736 100644 --- a/src/bot/events/interaction-create/checkin/messages/audit.ts +++ b/src/bot/events/interaction-create/checkin/messages/audit.ts @@ -9,11 +9,12 @@ 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: (msgLink: string, userDiscordId: string) => `βœ… Successfully [audited check-in](${msgLink}) for <@${userDiscordId}>πŸŽ‰`, } } diff --git a/src/bot/events/interaction-create/checkin/validators/audit.ts b/src/bot/events/interaction-create/checkin/validators/audit.ts index 713916a..388409e 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, 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!)) diff --git a/src/utils/discord/assert.ts b/src/utils/discord/assert.ts index ee3c213..83bda9c 100644 --- a/src/utils/discord/assert.ts +++ b/src/utils/discord/assert.ts @@ -1,4 +1,4 @@ -import type { ClientUser, Guild, GuildMember, Message, Role, TextChannel, ThreadAutoArchiveDuration } 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 '.' @@ -40,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) @@ -93,7 +102,7 @@ export class DiscordAssert extends DiscordMessage { throw new DiscordAssertError(this.ERR.AllowedChannel(channelId)) } - const channel = await getChannel(guild, channelId) + const channel = await getChannel(guild, channelId) as TextChannel this.assertChannel(channel) return channel @@ -116,6 +125,36 @@ export class DiscordAssert extends DiscordMessage { } } + static async assertThreadUnderChannel(guild: Guild, currentChannelId: string, parentChannel: TextChannel) { + const thread = await getChannel(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/message.ts b/src/utils/discord/message.ts index ec2c10c..943123a 100644 --- a/src/utils/discord/message.ts +++ b/src/utils/discord/message.ts @@ -22,6 +22,11 @@ export class DiscordMessage { 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', From 1a5cbc800a98ea16e71fe729432ef906899c0ed1 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 18:32:32 +0700 Subject: [PATCH 09/18] chore: permanent audit success message --- .../events/interaction-create/checkin/handlers/audit-modal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..0e3d9bc 100644 --- a/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts +++ b/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts @@ -55,7 +55,7 @@ registerInteractionHandler({ true, ) - await sendReply(interaction, CheckinAudit.MSG.AuditSuccess(updatedCheckin.link!, updatedCheckin.user!.discord_id)) + await sendReply(interaction, CheckinAudit.MSG.AuditSuccess(updatedCheckin.link!, updatedCheckin.user!.discord_id), false) } catch (err: any) { if (err instanceof DiscordBaseError) From a1cd9456b0c57f75a4c080fa677c0400b9720200 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 18:34:52 +0700 Subject: [PATCH 10/18] refactor: `getChannel` return can also become a thread type --- .../grinder-role/handlers/index.ts | 3 ++- .../interaction-create/checkin/validators/index.ts | 4 ++-- .../embed/handlers/role-grant-create-modal.ts | 3 ++- .../interaction-create/message/handlers/send-modal.ts | 4 ++-- src/utils/discord/index.ts | 11 ++++++++--- 5 files changed, 16 insertions(+), 9 deletions(-) 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..2a904d0 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,3 +1,4 @@ +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' @@ -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 getChannel(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/validators/index.ts b/src/bot/events/interaction-create/checkin/validators/index.ts index 79cdae5..a7bc4bd 100644 --- a/src/bot/events/interaction-create/checkin/validators/index.ts +++ b/src/bot/events/interaction-create/checkin/validators/index.ts @@ -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 getChannel(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 getChannel(guild, CHECKIN_CHANNEL) as TextChannel await sendAsBot(null, checkinChannel, { content: `Hey, <@${member.id}>. You already have <@&${newRole.id}>`, allowedMentions: { users: [member.id], roles: [] }, 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..8faed0f 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,3 +1,4 @@ +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' @@ -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 getChannel(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..7347be4 100644 --- a/src/bot/events/interaction-create/message/handlers/send-modal.ts +++ b/src/bot/events/interaction-create/message/handlers/send-modal.ts @@ -1,4 +1,4 @@ -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' @@ -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 getChannel(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/index.ts b/src/utils/discord/index.ts index 0508d71..a393b61 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 getChannel(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 { From 95cb2701fb65d30e35d5b2cbf636c663a186aa7f Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 18:38:01 +0700 Subject: [PATCH 11/18] refactor: `getChannel` into `getChannelOrThread` --- .../client-ready/jobs/handlers/reset-grinder-roles.ts | 6 +++--- .../guild-member-update/grinder-role/handlers/index.ts | 4 ++-- .../events/interaction-create/checkin/validators/index.ts | 6 +++--- .../embed/handlers/role-grant-create-modal.ts | 4 ++-- .../interaction-create/message/handlers/send-modal.ts | 4 ++-- src/utils/discord/assert.ts | 6 +++--- src/utils/discord/index.ts | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) 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 2a904d0..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 @@ -2,7 +2,7 @@ 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' @@ -27,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) as TextChannel + 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/validators/index.ts b/src/bot/events/interaction-create/checkin/validators/index.ts index a7bc4bd..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) as TextChannel + 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) as TextChannel + 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: [] }, 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 8faed0f..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 @@ -2,7 +2,7 @@ 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' @@ -31,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) as TextChannel + 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 7347be4..2f66cae 100644 --- a/src/bot/events/interaction-create/message/handlers/send-modal.ts +++ b/src/bot/events/interaction-create/message/handlers/send-modal.ts @@ -2,7 +2,7 @@ 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) as TextChannel + 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 83bda9c..105be25 100644 --- a/src/utils/discord/assert.ts +++ b/src/utils/discord/assert.ts @@ -1,7 +1,7 @@ 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' @@ -102,7 +102,7 @@ export class DiscordAssert extends DiscordMessage { throw new DiscordAssertError(this.ERR.AllowedChannel(channelId)) } - const channel = await getChannel(guild, channelId) as TextChannel + const channel = await getChannelOrThread(guild, channelId) as TextChannel this.assertChannel(channel) return channel @@ -126,7 +126,7 @@ export class DiscordAssert extends DiscordMessage { } static async assertThreadUnderChannel(guild: Guild, currentChannelId: string, parentChannel: TextChannel) { - const thread = await getChannel(guild, currentChannelId) as ThreadChannel + const thread = await getChannelOrThread(guild, currentChannelId) as ThreadChannel if (!thread.isThread()) throw new DiscordAssertError(this.ERR.MustBeThread(parentChannel.id)) diff --git a/src/utils/discord/index.ts b/src/utils/discord/index.ts index a393b61..7877fc1 100644 --- a/src/utils/discord/index.ts +++ b/src/utils/discord/index.ts @@ -1,7 +1,7 @@ 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, isThread: boolean = false): Promise { +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) } From f7a23230cc6ef2237965058d303e9a6e062d30be Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 18:38:28 +0700 Subject: [PATCH 12/18] feat: thread assertions on `/checkin-audit` command --- src/bot/commands/checkin/handlers/checkin-audit.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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) From 5898cd84c7e7939b759f0faea14c14d64f0680c8 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 18:55:57 +0700 Subject: [PATCH 13/18] feat: close thread after audit the check-in --- .../interaction-create/checkin/handlers/audit-modal.ts | 7 ++++--- .../events/interaction-create/checkin/validators/audit.ts | 8 +++++++- 2 files changed, 11 insertions(+), 4 deletions(-) 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 0e3d9bc..1784be8 100644 --- a/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts +++ b/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts @@ -1,5 +1,5 @@ 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' @@ -35,8 +35,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,6 +55,7 @@ registerInteractionHandler({ true, ) + CheckinAudit.closeClarificationThread(thread, threadMsg) await sendReply(interaction, CheckinAudit.MSG.AuditSuccess(updatedCheckin.link!, updatedCheckin.user!.discord_id), false) } catch (err: any) { diff --git a/src/bot/events/interaction-create/checkin/validators/audit.ts b/src/bot/events/interaction-create/checkin/validators/audit.ts index 388409e..396bc3a 100644 --- a/src/bot/events/interaction-create/checkin/validators/audit.ts +++ b/src/bot/events/interaction-create/checkin/validators/audit.ts @@ -1,7 +1,7 @@ import type { PrismaClient } from '@generatedDB/client' import type { Checkin as CheckinType } from '@type/checkin' import type { CheckinStreak } from '@type/checkin-streak' -import type { Interaction, ThreadChannel } 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' @@ -114,4 +114,10 @@ ${checkin.public_id} return checkinStreak.checkins! } + + static closeClarificationThread(thread: ThreadChannel, threadMessage: Message) { + threadMessage.react('πŸ”₯') + thread.setLocked(true) + thread.setArchived(true) + } } From 37744d3d33cfdf07a0837848d7e32d5335b94959 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 19:14:21 +0700 Subject: [PATCH 14/18] chore: await close thread --- .../interaction-create/checkin/handlers/audit-modal.ts | 4 ++-- .../events/interaction-create/checkin/validators/audit.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) 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 1784be8..e1f5dd1 100644 --- a/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts +++ b/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts @@ -55,8 +55,8 @@ registerInteractionHandler({ true, ) - CheckinAudit.closeClarificationThread(thread, threadMsg) - await sendReply(interaction, CheckinAudit.MSG.AuditSuccess(updatedCheckin.link!, updatedCheckin.user!.discord_id), false) + await sendReply(interaction, CheckinAudit.MSG.AuditSuccess(updatedCheckin.link!, flamewarden.id, updatedCheckin.user!.discord_id), false) + await CheckinAudit.closeClarificationThread(thread, threadMsg) } catch (err: any) { if (err instanceof DiscordBaseError) diff --git a/src/bot/events/interaction-create/checkin/validators/audit.ts b/src/bot/events/interaction-create/checkin/validators/audit.ts index 396bc3a..efd2367 100644 --- a/src/bot/events/interaction-create/checkin/validators/audit.ts +++ b/src/bot/events/interaction-create/checkin/validators/audit.ts @@ -115,9 +115,9 @@ ${checkin.public_id} return checkinStreak.checkins! } - static closeClarificationThread(thread: ThreadChannel, threadMessage: Message) { - threadMessage.react('πŸ”₯') - thread.setLocked(true) - thread.setArchived(true) + static async closeClarificationThread(thread: ThreadChannel, threadMessage: Message) { + await threadMessage.react('πŸ”₯') + await thread.setLocked(true) + await thread.setArchived(true) } } From 5e3e6e989adff51814cb54fd5453df6941c681fe Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 19:14:51 +0700 Subject: [PATCH 15/18] chore: audit message --- src/bot/commands/checkin/messages/checkin-status.ts | 4 ++-- .../checkin/handlers/status-clarification-button.ts | 2 +- .../interaction-create/checkin/messages/audit.ts | 11 ++++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/bot/commands/checkin/messages/checkin-status.ts b/src/bot/commands/checkin/messages/checkin-status.ts index 9d9e6ad..f936147 100644 --- a/src/bot/commands/checkin/messages/checkin-status.ts +++ b/src/bot/commands/checkin/messages/checkin-status.ts @@ -29,7 +29,7 @@ Silakan gunakan thread ini untuk mendiskusikan detail *check-in* bersama <@&${FL `, 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."* @@ -76,7 +76,7 @@ ${checkin.public_id} `, 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/events/interaction-create/checkin/handlers/status-clarification-button.ts b/src/bot/events/interaction-create/checkin/handlers/status-clarification-button.ts index 49f3f14..4aef35c 100644 --- a/src/bot/events/interaction-create/checkin/handlers/status-clarification-button.ts +++ b/src/bot/events/interaction-create/checkin/handlers/status-clarification-button.ts @@ -48,7 +48,7 @@ registerInteractionHandler({ await thread.send({ content: CheckinStatus.MSG.ThreadContent(checkin) }) await sendReply(interaction, CheckinStatus.MSG.ThreadCreated(thread)) - interaction.message.react(CheckinStatus.CLARIFICATION_EMOJI) + await interaction.message.react(CheckinStatus.CLARIFICATION_EMOJI) } catch (err: any) { if (err instanceof DiscordBaseError) diff --git a/src/bot/events/interaction-create/checkin/messages/audit.ts b/src/bot/events/interaction-create/checkin/messages/audit.ts index 700c736..b9f5cad 100644 --- a/src/bot/events/interaction-create/checkin/messages/audit.ts +++ b/src/bot/events/interaction-create/checkin/messages/audit.ts @@ -1,5 +1,7 @@ import type { Checkin } from '@type/checkin' +import { getNow, getParsedNow } from '@utils/date' import { DiscordAssert } from '@utils/discord' +import { DUMMY } from '@utils/placeholder' export class CheckinAuditMessage extends DiscordAssert { static override readonly ERR = { @@ -15,6 +17,13 @@ ${waitingCheckinList} 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}> + +> ${DUMMY.FOOTER} + `, } } From 5b35b57025f0eb5e52bbc0d10152a48a5d0aad64 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 19:26:31 +0700 Subject: [PATCH 16/18] feat: close with embed --- .../checkin/handlers/audit-modal.ts | 12 ++++++++++-- .../interaction-create/checkin/messages/audit.ts | 3 +-- 2 files changed, 11 insertions(+), 4 deletions(-) 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 e1f5dd1..0e30fab 100644 --- a/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts +++ b/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts @@ -3,10 +3,11 @@ 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' @@ -55,7 +56,14 @@ registerInteractionHandler({ true, ) - await sendReply(interaction, CheckinAudit.MSG.AuditSuccess(updatedCheckin.link!, flamewarden.id, updatedCheckin.user!.discord_id), false) + 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) { diff --git a/src/bot/events/interaction-create/checkin/messages/audit.ts b/src/bot/events/interaction-create/checkin/messages/audit.ts index b9f5cad..0dbd9cd 100644 --- a/src/bot/events/interaction-create/checkin/messages/audit.ts +++ b/src/bot/events/interaction-create/checkin/messages/audit.ts @@ -1,7 +1,6 @@ import type { Checkin } from '@type/checkin' import { getNow, getParsedNow } from '@utils/date' import { DiscordAssert } from '@utils/discord' -import { DUMMY } from '@utils/placeholder' export class CheckinAuditMessage extends DiscordAssert { static override readonly ERR = { @@ -23,7 +22,7 @@ Wahai Tuan/Nona <@${userDiscordId}>, πŸ—“ **Audited At**: ${getParsedNow(getNow())} πŸ‘€ **Audited By**: <@${flamewardenId}> -> ${DUMMY.FOOTER} +> *"Api telah diuji, dan keputusannya kini tercatat dalam Aksaria."* `, } } From e4790af8287264b4b8530ba73633537aad008e48 Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 19:36:26 +0700 Subject: [PATCH 17/18] chore: typography --- src/bot/commands/checkin/messages/checkin-status.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bot/commands/checkin/messages/checkin-status.ts b/src/bot/commands/checkin/messages/checkin-status.ts index f936147..97f5c09 100644 --- a/src/bot/commands/checkin/messages/checkin-status.ts +++ b/src/bot/commands/checkin/messages/checkin-status.ts @@ -18,6 +18,8 @@ export class CheckinStatusMessage extends DiscordAssert { 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: From c23e1cf21b0e93753b5fee2797cf0fc7ebccd4ab Mon Sep 17 00:00:00 2001 From: alfianchii Date: Fri, 26 Dec 2025 19:39:49 +0700 Subject: [PATCH 18/18] chore: 'reviewed by' with tagged reviewer --- src/bot/commands/checkin/messages/checkin-status.ts | 4 ++-- src/bot/events/interaction-create/checkin/messages/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bot/commands/checkin/messages/checkin-status.ts b/src/bot/commands/checkin/messages/checkin-status.ts index 97f5c09..b9ccd73 100644 --- a/src/bot/commands/checkin/messages/checkin-status.ts +++ b/src/bot/commands/checkin/messages/checkin-status.ts @@ -58,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."* `, @@ -72,7 +72,7 @@ ${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."* `, diff --git a/src/bot/events/interaction-create/checkin/messages/index.ts b/src/bot/events/interaction-create/checkin/messages/index.ts index 3d71186..b491cf6 100644 --- a/src/bot/events/interaction-create/checkin/messages/index.ts +++ b/src/bot/events/interaction-create/checkin/messages/index.ts @@ -55,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`, @@ -68,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`,