diff --git a/.env.example b/.env.example index bae1c1c..9713e5e 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ GUIDES_CHANNEL_ID=your_guides_channel_id_here ADVENT_OF_CODE_CHANNEL_ID=your_advent_of_code_forum_channel_id_here REPEL_LOG_CHANNEL_ID=your_repel_log_channel_id_here ONBOARDING_CHANNEL_ID=onboarding_channel_id_here +ARCHIVE_CATEGORY_ID=archive_category_id_here # Role IDs (REQUIRED) MODERATORS_ROLE_IDS=role_id_1,role_id_2,role_id_3 diff --git a/.env.production b/.env.production index 1b04eb7..d3660a6 100644 --- a/.env.production +++ b/.env.production @@ -12,6 +12,7 @@ ADVENT_OF_CODE_CHANNEL_ID=1047623689488830495 SHOWCASE_CHANNEL_ID=1517161718818541658 SHOWCASE_LOG_CHANNEL_ID=1517565847982444634 SHOWCASE_RULES_CHANNEL_ID=1517948527098073158 +ARCHIVE_CATEGORY_ID=837507969859977258 # Role IDs (from your dev server) diff --git a/.env.test b/.env.test index c48bb66..0e25d88 100644 --- a/.env.test +++ b/.env.test @@ -19,6 +19,7 @@ REPEL_LOG_CHANNEL_ID=your-repel-log-channel-id SHOWCASE_CHANNEL_ID=your-showcase-forum-channel-id SHOWCASE_LOG_CHANNEL_ID=your-showcase-log-channel-id SHOWCASE_RULES_CHANNEL_ID=your-showcase-rules-channel-id +ARCHIVE_CATEGORY_ID=your-archived-category-id # Role IDs (from your dev server) REPEL_ROLE_ID=your-repel-role-id diff --git a/src/common/events/index.ts b/src/common/events/index.ts index 05330c9..2b8c335 100644 --- a/src/common/events/index.ts +++ b/src/common/events/index.ts @@ -3,10 +3,12 @@ import { hasVarEvent } from '@/features/has-var/index.js'; import { interactionCreateEvent } from '@/features/interaction-create/index.js'; import { readyEvent } from '@/features/ready/index.js'; import type { DiscordEvent } from './types.js'; +import archiveChannels from '@/features/archive-channels/index.js'; export const events: DiscordEvent[] = [ readyEvent, guildCreateEvent, hasVarEvent, interactionCreateEvent, + archiveChannels, ].flat(); diff --git a/src/env.ts b/src/env.ts index c35b3b6..0a66798 100644 --- a/src/env.ts +++ b/src/env.ts @@ -41,6 +41,7 @@ export const config = { showcase: requireEnv('SHOWCASE_CHANNEL_ID'), showcaseLogs: requireEnv('SHOWCASE_LOG_CHANNEL_ID'), showcaseRules: requireEnv('SHOWCASE_RULES_CHANNEL_ID'), + archiveCategory: requireEnv('ARCHIVE_CATEGORY_ID'), }, onboarding: { channelId: optionalEnv('ONBOARDING_CHANNEL_ID'), diff --git a/src/features/archive-channels/index.ts b/src/features/archive-channels/index.ts new file mode 100644 index 0000000..26a2049 --- /dev/null +++ b/src/features/archive-channels/index.ts @@ -0,0 +1,38 @@ +import { createEvent } from '@/common/events/create-event.js'; +import { config } from '@/env.js'; +import { Events } from 'discord.js'; +import { + archiveChannel, + processingChannels, + unarchiveChannel, +} from './util.js'; + +export default createEvent( + { + name: Events.ChannelUpdate, + }, + async (oldChannel, newChannel) => { + if (newChannel.isDMBased() || oldChannel.isDMBased()) { + return; + } + + // We only care about channels that are being moved to or out of the archived category + if ( + newChannel.parentId !== config.channelIds.archiveCategory && + oldChannel.parentId !== config.channelIds.archiveCategory + ) { + return; + } + + // Prevent deadlocks from nested ChannelUpdate events caused by our own edits + if (processingChannels.has(newChannel.id)) { + return; + } + + if (newChannel.parentId === config.channelIds.archiveCategory) { + await archiveChannel(newChannel); + } else { + await unarchiveChannel(newChannel); + } + } +); diff --git a/src/features/archive-channels/util.ts b/src/features/archive-channels/util.ts new file mode 100644 index 0000000..9d2939f --- /dev/null +++ b/src/features/archive-channels/util.ts @@ -0,0 +1,129 @@ +import { config } from '@/env.js'; +import { + Guild, + ChannelType, + type GuildChannel, + PermissionFlagsBits, +} from 'discord.js'; + +export const processingChannels = new Set(); + +export const ARCHIVED_REGEX = /^archived-/; + +export const PUBLIC_PERMISSIONS = [ + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.CreatePublicThreads, + PermissionFlagsBits.CreatePrivateThreads, + PermissionFlagsBits.SendMessagesInThreads, + 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; + const results = await Promise.allSettled( + archivedChannels.map(archiveChannel) + ); + + const failedReasons = []; + for (const result of results) { + if (result.status === 'rejected') { + failedReasons.push(result.reason); + } + } + + if (failedReasons.length > 0) { + const errorMessages = failedReasons + .map((reason) => reason.message || reason) + .join('; '); + console.error(`Failed to archive some channels: ${errorMessages}`); + throw new Error(`Failed to archive some channels: ${errorMessages}`); + } +} +export async function setArchivedPermissions( + channel: GuildChannel, + archived: boolean +) { + await channel.permissionOverwrites.edit( + channel.guild.roles.everyone.id, + { + SendMessages: archived ? false : null, + CreatePublicThreads: archived ? false : null, + CreatePrivateThreads: archived ? false : null, + SendMessagesInThreads: archived ? false : null, + Connect: archived ? false : null, + }, + { + reason: `${archived ? 'Archiving' : 'Unarchiving'} channel ${channel.name}`, + } + ); +} +export function hasArchivedPermissions(channel: GuildChannel) { + const everyoneRole = channel.guild.roles.everyone; + const overwrite = channel.permissionOverwrites.cache.get(everyoneRole.id); + + if (!overwrite) { + return false; + } + + return PUBLIC_PERMISSIONS.every((permission) => + overwrite.deny.has(permission) + ); +} +export async function unarchiveChannel(channel: GuildChannel) { + const channelName = channel.name; + + if (!channelName.match(ARCHIVED_REGEX)) { + return; + } + + const newChannelName = channelName.replace(ARCHIVED_REGEX, ''); + + processingChannels.add(channel.id); + + try { + await setArchivedPermissions(channel, false); + await channel.setName(newChannelName); + } catch (error) { + console.error( + `Error unarchiving channel ${channelName}:`, + (error as Error).message + ); + } finally { + processingChannels.delete(channel.id); + } +} +export async function archiveChannel(channel: GuildChannel) { + const channelName = channel.name; + + const archivedChannelName = channelName.match(ARCHIVED_REGEX) + ? channelName + : `archived-${channelName}`; + + if (archivedChannelName === channelName && hasArchivedPermissions(channel)) { + return; + } + + processingChannels.add(channel.id); + + try { + const renamedChannel = await channel.setName(archivedChannelName); + await setArchivedPermissions(renamedChannel, true); + } catch (error) { + // console.error(`Error archiving channel ${channelName}:`, error); + throw new Error( + `Error archiving channel ${channelName}: ${(error as Error).message}` + ); + } finally { + processingChannels.delete(channel.id); + } +} diff --git a/src/features/ready/index.ts b/src/features/ready/index.ts index a95c651..95c365c 100644 --- a/src/features/ready/index.ts +++ b/src/features/ready/index.ts @@ -5,6 +5,7 @@ import { initializeAdventScheduler } from '@/util/advent-scheduler.js'; import { fetchAndCachePublicChannelsMessages } from '@/util/cache.js'; import { syncGuidesToChannel } from '@/util/post-guides.js'; import { leaveIfNotAllowedServer } from '@/util/server-guard.js'; +import { syncArchiveCategoryChannels } from '../archive-channels/util.js'; export const readyEvent = createEvent( { @@ -20,11 +21,17 @@ export const readyEvent = createEvent( await leaveIfNotAllowedServer(guild); } + const guild = client.guilds.cache.get(config.discord.serverId); + if (!guild) { + console.error( + `❌ Bot is not in the configured server with ID ${config.discord.serverId}` + ); + console.error('Please check your .env file or CI/CD configuration'); + process.exit(1); + } + if (config.fetchAndSyncMessages) { - const guild = client.guilds.cache.get(config.discord.serverId); - if (guild) { - await fetchAndCachePublicChannelsMessages(guild, true); - } + await fetchAndCachePublicChannelsMessages(guild, true); // Sync guides to channel try { @@ -54,5 +61,15 @@ export const readyEvent = createEvent( } catch (error) { console.error('❌ Failed to initialize Advent of Code scheduler:', error); } + + // Make sure all channels in the archived category are properly archived on startup + try { + await syncArchiveCategoryChannels(guild); + } catch (error) { + console.error( + '❌ Failed to ensure archived channels are properly archived:', + error + ); + } } );