diff --git a/src/constants/channels.ts b/src/constants/channels.ts new file mode 100644 index 0000000..63468d2 --- /dev/null +++ b/src/constants/channels.ts @@ -0,0 +1,72 @@ +import type { + CategoryChannel, + ForumChannel, + Guild, + TextChannel, +} from 'discord.js'; +import { ChannelType } from 'discord.js'; +import { config } from '@/env.js'; + +export type ChannelKey = keyof typeof config.channelIds; + +type ChannelTypeMap = { + repelLogs: TextChannel; + guides: TextChannel; + adventOfCode: ForumChannel; + showcase: ForumChannel; + showcaseLogs: TextChannel; + showcaseRules: TextChannel; + spamDetection: TextChannel; + archiveCategory: CategoryChannel; +}; + +const EXPECTED_DISCORD_TYPE: Record = { + repelLogs: ChannelType.GuildText, + guides: ChannelType.GuildText, + adventOfCode: ChannelType.GuildForum, + showcase: ChannelType.GuildForum, + showcaseLogs: ChannelType.GuildText, + showcaseRules: ChannelType.GuildText, + spamDetection: ChannelType.GuildText, + archiveCategory: ChannelType.GuildCategory, +}; + +const resolveChannel = ( + guild: Guild, + key: Key +): ChannelTypeMap[Key] => { + const channelId = config.channelIds[key]; + const channel = guild.channels.cache.get(channelId); + + if (!channel) { + throw new Error( + `Channel with ID ${channelId} (key: ${key}) not found in the guild.` + ); + } + + const expectedType = EXPECTED_DISCORD_TYPE[key]; + if (channel.type !== expectedType) { + throw new Error( + `Channel "${key}" (${channelId}) has type ${ChannelType[channel.type]}, expected ${ChannelType[expectedType]}.` + ); + } + + return channel as ChannelTypeMap[Key]; +}; + +export const SERVER_CHANNELS = {} as { + [Key in ChannelKey]: ChannelTypeMap[Key]; +}; + +const assignResolvedChannel = ( + key: Key, + channel: ChannelTypeMap[Key] +): void => { + SERVER_CHANNELS[key] = channel; +}; + +export const resolveChannels = (guild: Guild): void => { + (Object.keys(config.channelIds) as ChannelKey[]).forEach((key) => { + assignResolvedChannel(key, resolveChannel(guild, key)); + }); +}; diff --git a/src/features/archive-channels/util.ts b/src/features/archive-channels/util.ts index c5fd426..2fa95d3 100644 --- a/src/features/archive-channels/util.ts +++ b/src/features/archive-channels/util.ts @@ -1,6 +1,5 @@ -import { config } from '@/env.js'; +import { SERVER_CHANNELS } from '@/constants/channels.js'; import { - Guild, ChannelType, type GuildChannel, PermissionFlagsBits, @@ -18,18 +17,8 @@ export const PUBLIC_PERMISSIONS = [ PermissionFlagsBits.Connect, ]; -export async function syncArchiveCategoryChannels(guild: Guild) { - const archiveCategory = guild.channels.cache.get( - config.channelIds.archiveCategory - ); - - if (archiveCategory?.type !== ChannelType.GuildCategory) { - throw new Error( - `Archive category with ID ${config.channelIds.archiveCategory} not found in the guild.` - ); - } - - const archivedChannels = archiveCategory.children.cache; +export async function syncArchiveCategoryChannels() { + const archivedChannels = SERVER_CHANNELS.archiveCategory.children.cache; const results = await Promise.allSettled( archivedChannels.map(archiveChannel) ); diff --git a/src/features/moderation/repel.ts b/src/features/moderation/repel.ts index b6826c8..3a22b7b 100644 --- a/src/features/moderation/repel.ts +++ b/src/features/moderation/repel.ts @@ -21,6 +21,7 @@ import { config } from '../../env.js'; import { buildCommandString } from '../../util/build-command-string.js'; import { getPublicChannels } from '../../util/channel.js'; import { logToChannel } from '../../util/channel-logging.js'; +import { SERVER_CHANNELS } from '@/constants/channels.js'; const DEFAULT_LOOK_BACK_MS = 10 * MINUTE; const DEFAULT_TIMEOUT_DURATION_MS = 1 * HOUR; @@ -370,17 +371,13 @@ const logRepelAction = async ({ const mentionText = modMessage ? `${config.roleIds.moderators.map((id) => `<@&${id}>`).join(' ')} - ${modMessage}` : undefined; - const channel = interaction.client.channels.cache.get( - config.channelIds.repelLogs - ) as TextChannel; - const embed = failedChannelsEmbed !== null ? [commandEmbed, resultEmbed, failedChannelsEmbed] : [commandEmbed, resultEmbed]; await logToChannel({ - channel, + channel: SERVER_CHANNELS.repelLogs, content: { type: 'embed', embed, diff --git a/src/features/ready/index.ts b/src/features/ready/index.ts index c823134..f7eef82 100644 --- a/src/features/ready/index.ts +++ b/src/features/ready/index.ts @@ -1,5 +1,6 @@ import { Events } from 'discord.js'; import { createEvent } from '@/common/events/create-event.js'; +import { resolveChannels } from '@/constants/channels.js'; import { config } from '@/env.js'; import { initializeAdventScheduler } from '@/util/advent-scheduler.js'; import { fetchAndCachePublicChannelsMessages } from '@/util/channel-prefetch.js'; @@ -31,28 +32,29 @@ export const readyEvent = createEvent( process.exit(1); } + resolveChannels(guild); + if (config.fetchAndSyncMessages) { await fetchAndCachePublicChannelsMessages(guild, true); - - // Sync guides to channel - try { - console.log( - `🔄 Starting guide sync to channel ${config.channelIds.guides}...` - ); - await syncGuidesToChannel(client, config.channelIds.guides); - } catch (error) { - if (error && typeof error === 'object' && 'code' in error) { - const discordError = error as { code: number; message?: string }; - if (discordError.code === 50001) { - console.warn( - '⚠️ Bot does not have access to the guides channel. Please check bot permissions and channel ID.' - ); - } else { - console.error('❌ Failed to sync guides:', error); - } + } + // Sync guides to channel + try { + console.log( + `🔄 Starting guide sync to channel ${config.channelIds.guides}...` + ); + await syncGuidesToChannel(client, config.channelIds.guides); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error) { + const discordError = error as { code: number; message?: string }; + if (discordError.code === 50001) { + console.warn( + '⚠️ Bot does not have access to the guides channel. Please check bot permissions and channel ID.' + ); } else { console.error('❌ Failed to sync guides:', error); } + } else { + console.error('❌ Failed to sync guides:', error); } } @@ -65,7 +67,7 @@ export const readyEvent = createEvent( // Make sure all channels in the archived category are properly archived on startup try { - await syncArchiveCategoryChannels(guild); + await syncArchiveCategoryChannels(); } catch (error) { console.error( '❌ Failed to ensure archived channels are properly archived:', diff --git a/src/features/report-message/index.ts b/src/features/report-message/index.ts index 2a5e23c..608f7b7 100644 --- a/src/features/report-message/index.ts +++ b/src/features/report-message/index.ts @@ -1,6 +1,6 @@ import { createMessageContextMenuCommand } from '@/common/commands/create-commands.js'; -import { config } from '@/env.js'; -import { ChannelType, Colors, EmbedBuilder, MessageFlags } from 'discord.js'; +import { SERVER_CHANNELS } from '@/constants/channels.js'; +import { Colors, EmbedBuilder, MessageFlags } from 'discord.js'; export const reportMessage = createMessageContextMenuCommand({ data: { @@ -22,17 +22,8 @@ export const reportMessage = createMessageContextMenuCommand({ const targetMessage = interaction.targetMessage; const reporter = interaction.user; - const channelId = config.channelIds.spamDetection; - const channel = guild.channels.cache.get(channelId); try { - if (!channel || channel.type !== ChannelType.GuildText) { - await interaction.editReply({ - content: 'Moderator channel not found or is not a text channel.', - }); - return; - } - const jumpLink = targetMessage.url; const authorTag = targetMessage.author.tag ?? 'Unknown'; const authorId = targetMessage.author.id ?? 'Unknown'; @@ -55,7 +46,7 @@ export const reportMessage = createMessageContextMenuCommand({ { name: 'Linked User', value: `<@${authorId}>`, inline: true } ); - await channel.send({ embeds: [embed] }); + await SERVER_CHANNELS.spamDetection.send({ embeds: [embed] }); await interaction.editReply({ content: 'Thanks. The message was reported to moderators.', diff --git a/src/features/showcase/create-showcase.ts b/src/features/showcase/create-showcase.ts index 0755dfc..85e766e 100644 --- a/src/features/showcase/create-showcase.ts +++ b/src/features/showcase/create-showcase.ts @@ -2,7 +2,6 @@ import { ButtonBuilder, type ButtonInteraction, ButtonStyle, - ChannelType, type ChatInputCommandInteraction, Colors, ContainerBuilder, @@ -19,36 +18,20 @@ import { type ModalSubmitInteraction, registerModalSubmitInteraction, } from '@/common/interactions/modal-interaction.js'; -import { config } from '@/env.js'; import { logToChannel } from '@/util/channel-logging.js'; import { customId } from '@/util/custom-id.js'; import { deleteShowcase } from './delete-showcase.js'; import { editShowcaseInteraction } from './edit-showcase.js'; -import { - buildShowcaseModal, - createShowcaseMessageContent, - getShowcaseLogChannel, -} from './util.js'; +import { buildShowcaseModal, createShowcaseMessageContent } from './util.js'; +import { SERVER_CHANNELS } from '@/constants/channels.js'; export const showModal = async ( interaction: ButtonInteraction | ChatInputCommandInteraction ) => { try { - const channel = interaction.guild?.channels.cache.get( - config.channelIds.showcase - ); - if (channel === undefined || channel.type !== ChannelType.GuildForum) { - await interaction.reply({ - content: - 'Showcase channel is not properly configured. Please contact an administrator.', - flags: MessageFlags.Ephemeral, - }); - return; - } - const modal = buildShowcaseModal({ id: customId('showcase', interaction.user.id), - tags: channel.availableTags, + tags: SERVER_CHANNELS.showcase.availableTags, }); await interaction.showModal(modal); @@ -88,19 +71,8 @@ const modalHandler: ModalSubmitInteraction = { const projectTags = interaction.fields.getStringSelectValues('projectTags'); const projectMedia = interaction.fields.getUploadedFiles('projectMedia'); - const channel = interaction.guild?.channels.cache.get( - config.channelIds.showcase - ); - if (channel === undefined || channel.type !== ChannelType.GuildForum) { - await interaction.editReply({ - content: - 'Showcase channel is not properly configured. Please contact an administrator.', - }); - return; - } - try { - const thread = await channel.threads.create({ + const thread = await SERVER_CHANNELS.showcase.threads.create({ name: projectName, appliedTags: projectTags, message: { @@ -142,7 +114,6 @@ const modalHandler: ModalSubmitInteraction = { }); try { - const logChannel = getShowcaseLogChannel(interaction.guild); const author = { name: interaction.user.tag, iconURL: interaction.user.displayAvatarURL(), @@ -164,7 +135,7 @@ const modalHandler: ModalSubmitInteraction = { .setTimestamp(); await logToChannel({ - channel: logChannel, + channel: SERVER_CHANNELS.showcaseLogs, content: { type: 'embed', embed }, silent: true, }); diff --git a/src/features/showcase/delete-showcase.ts b/src/features/showcase/delete-showcase.ts index 5484ec8..2385adc 100644 --- a/src/features/showcase/delete-showcase.ts +++ b/src/features/showcase/delete-showcase.ts @@ -3,7 +3,7 @@ import type { ButtonSubmitInteraction } from '@/common/interactions/button-inter import { logToChannel } from '@/util/channel-logging.js'; import { parseCustomId } from '@/util/custom-id.js'; import { isUserInServer, isUserModerator } from '@/util/member.js'; -import { getShowcaseLogChannel } from './util.js'; +import { SERVER_CHANNELS } from '@/constants/channels.js'; export const deleteShowcase: ButtonSubmitInteraction = { commandName: 'delete_showcase', @@ -61,9 +61,8 @@ export const deleteShowcase: ButtonSubmitInteraction = { const projectName = forumPost.name; await interaction.channel?.delete(); - const logChannel = getShowcaseLogChannel(interaction.guild); await logToChannel({ - channel: logChannel, + channel: SERVER_CHANNELS.showcaseLogs, content: { type: 'embed', embed: new EmbedBuilder() diff --git a/src/features/showcase/edit-showcase.ts b/src/features/showcase/edit-showcase.ts index a9f0bf8..771c039 100644 --- a/src/features/showcase/edit-showcase.ts +++ b/src/features/showcase/edit-showcase.ts @@ -19,10 +19,10 @@ import { buildShowcaseModal, createShowcaseMessageContent, getAttachmentsCount, - getShowcaseLogChannel, parseShowcaseMessage, resolveTagNames, } from './util.js'; +import { SERVER_CHANNELS } from '@/constants/channels.js'; export const editShowcaseInteraction: ButtonSubmitInteraction = { commandName: 'edit_showcase', @@ -266,7 +266,6 @@ const modalHandler: ModalSubmitInteraction = { if (changes.length > 0) { try { - const logChannel = getShowcaseLogChannel(interaction.guild); const author = { name: interaction.user.tag, iconURL: interaction.user.displayAvatarURL(), @@ -302,7 +301,7 @@ const modalHandler: ModalSubmitInteraction = { .setColor(Colors.Orange) .setTimestamp(); - await logChannel.send({ + await SERVER_CHANNELS.showcaseLogs.send({ embeds: [embed], allowedMentions: { parse: [] }, }); diff --git a/src/features/showcase/send-pinned-message.ts b/src/features/showcase/send-pinned-message.ts index 6acb71e..a930a09 100644 --- a/src/features/showcase/send-pinned-message.ts +++ b/src/features/showcase/send-pinned-message.ts @@ -8,7 +8,7 @@ import { PermissionsBitField, } from 'discord.js'; import { createSlashCommand } from '@/common/commands/create-commands.js'; -import { config } from '@/env.js'; +import { SERVER_CHANNELS } from '@/constants/channels.js'; export const sendShowcasePinnedMessage = createSlashCommand({ data: { @@ -20,15 +20,8 @@ export const sendShowcasePinnedMessage = createSlashCommand({ }, execute: async (interaction) => { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const showcaseChannel = interaction.guild?.channels.cache.get( - config.channelIds.showcaseRules - ); - if (showcaseChannel === undefined || !showcaseChannel.isTextBased()) { - await interaction.editReply({ - content: 'Showcase channel not found or is not a forum channel.', - }); - return; - } + + const showcaseChannel = SERVER_CHANNELS.showcaseRules; const guideLines = [ 'Welcome to the Showcase channel! Please read the rules and guidelines before posting your content. Make sure to follow the format and include all necessary information. Happy sharing!', diff --git a/src/features/showcase/util.ts b/src/features/showcase/util.ts index 69ef530..571e468 100644 --- a/src/features/showcase/util.ts +++ b/src/features/showcase/util.ts @@ -1,7 +1,6 @@ import type { Collection } from 'discord.js'; import { FileUploadBuilder, - type Guild, type GuildForumTag, LabelBuilder, ModalBuilder, @@ -10,7 +9,6 @@ import { TextInputBuilder, TextInputStyle, } from 'discord.js'; -import { config } from '@/env.js'; export type ShowcaseMessageData = { link: string; @@ -164,15 +162,3 @@ export const resolveTagNames = ( (id) => availableTags.find((tag) => tag.id === id)?.name ?? id ); }; - -export const getShowcaseLogChannel = (guild: Guild | null) => { - if (!guild) { - throw new Error('Guild is null'); - } - const channelId = config.channelIds.showcaseLogs; - const channel = guild.channels.cache.get(channelId); - if (!channel?.isTextBased() || !channel.isSendable()) { - throw new Error('Showcase log channel not found or is not text-based'); - } - return channel; -}; diff --git a/src/features/spam-detection/rules.ts b/src/features/spam-detection/rules.ts index 3a878b0..97fc130 100644 --- a/src/features/spam-detection/rules.ts +++ b/src/features/spam-detection/rules.ts @@ -1,6 +1,6 @@ import type { Message } from 'discord.js'; +import { SERVER_CHANNELS } from '@/constants/channels.js'; import { cachedMessages } from '@/util/cache/recent-message-store.js'; -import { config } from '../../env.js'; import { MAX_RULE_TIMEFRAME } from './constants.js'; import type { Rule } from './rules-config.js'; import { rules } from './rules-config.js'; @@ -29,9 +29,7 @@ export async function checkRules(newMessage: Message): Promise { }); if (result.broken) { - const logChannel = newMessage.client.channels.cache.get( - config.channelIds.spamDetection - ); + const logChannel = SERVER_CHANNELS.spamDetection; await rule.action(result.messages, rule, logChannel); return; }