import { ApplicationCommandDataResolvable, Client, ClientOptions, Collection, Guild, IntentsBitField, Snowflake, TextChannel } from "discord.js"; import fs from 'fs'; import { ScheduledTask, schedule } from "node-cron"; import { v4 as uuid } from 'uuid'; import { manageAnnouncementRoles } from "../commands/announce"; import { config } from "../configuration"; import { Maybe } from "../interfaces"; import { JellyfinHandler } from "../jellyfin/handler"; import { logger } from "../logger"; import { CommandType } from "../types/commandTypes"; import { isInitialAnnouncement } from "../helper/messageIdentifiers"; import VoteController from "../helper/vote.controller"; import { yavinJellyfinHandler } from "../.."; import RoleController from "../helper/role.controller"; export class ExtendedClient extends Client { private eventFilePath = `${__dirname}/../events` private commandFilePath = `${__dirname}/../commands` private jellyfin: JellyfinHandler public voteController: VoteController = new VoteController(this, yavinJellyfinHandler) public roleController: RoleController = new RoleController(this) public commands: Collection = new Collection() private announcementChannels: Collection = new Collection() //guildId to TextChannel private announcementRoleHandlerTask: Collection = new Collection() //one task per guild private pollCloseBackgroundTasks: Collection = new Collection() public constructor(jf: JellyfinHandler) { const intents: IntentsBitField = new IntentsBitField() intents.add(IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.DirectMessages, IntentsBitField.Flags.GuildScheduledEvents, IntentsBitField.Flags.GuildMessageReactions, IntentsBitField.Flags.GuildVoiceStates) const options: ClientOptions = { intents } super(options) this.jellyfin = jf } public async start() { if (process.env.NODE_ENV === 'test') return const promises: Promise[] = [] promises.push(this.registerSlashCommands()) promises.push(this.registerEventCallback()) Promise.all(promises).then(() => { this.login(config.bot.token) }) } private async importFile(filepath: string): Promise { logger.debug(`Importing ${filepath}`) const imported = await import(filepath) logger.debug(`Imported ${JSON.stringify(imported)}`) return imported.default ?? imported } public async registerCommands(cmds: ApplicationCommandDataResolvable[], guildIds: Collection) { if (guildIds) { guildIds.forEach(guild => { this.guilds.cache.get(guild.id)?.commands.set(cmds) logger.info(`Registering commands to ${guild.name}|${guild.id}`) }) } else { this.application?.commands.set(cmds) logger.info(`Registering global commands`) } return } public async registerSlashCommands(): Promise { try { const slashCommands: ApplicationCommandDataResolvable[] = [] const commandFiles = fs.readdirSync(this.commandFilePath).filter(file => file.endsWith('.ts') || file.endsWith('.js')) for (const commandFile of commandFiles) { const filePath = `${this.commandFilePath}/${commandFile}` const command = await this.importFile(filePath) logger.debug(JSON.stringify(command)) if (!command.name) return this.commands.set(command.name, command) slashCommands.push(command) } this.on("ready", async (client: Client) => { //logger.info(`Ready processing ${JSON.stringify(client)}`) logger.info(`SlashCommands: ${JSON.stringify(slashCommands)}`) const guilds = client.guilds.cache this.registerCommands(slashCommands, guilds) this.cacheUsers(guilds) await this.cacheAnnouncementServer(guilds) this.fetchAnnouncementChannelMessage(this.announcementChannels) this.startAnnouncementRoleBackgroundTask(guilds) this.startPollCloseBackgroundTasks() }) } catch (error) { logger.info(`Error refreshing slash commands: ${error}`) } } /** * Fetches all messages from the provided channel collection. * This is necessary for announcementChannels, because 'old' messages don't receive * messageReactionAdd Events, only messages that were sent while the bot is online are tracked * automatically. * To prevent the need for a dedicated 'Collector' implementation which would listen on specific * it's easiest to just fetch all messages from the backlog, which automatically makes the bot track them * again. * @param {Collection} channels - All channels which should be fecthed for reactionTracking */ private async fetchAnnouncementChannelMessage(channels: Collection): Promise { channels.each(async ch => { ch.messages.fetch() }) } private async cacheAnnouncementServer(guilds: Collection) { for (const guild of guilds.values()) { const channels: TextChannel[] = (await guild.channels.fetch()) ?.filter(channel => channel?.id === config.bot.announcement_channel_id) .map((value) => value) if (!channels || channels.length != 1) { logger.error(`Could not find announcement channel for guild ${guild.name} with guildId ${guild.id}. Found ${channels}`) continue } logger.info(`Fetched announcement channel: ${JSON.stringify(channels[0])}`) this.announcementChannels.set(guild.id, channels[0]) } } public getAnnouncementChannelForGuild(guildId: string): Maybe { return this.announcementChannels.get(guildId) } public async cacheUsers(guilds: Collection) { guilds.forEach((guild: Guild, id: Snowflake) => { logger.info(`Fetching members for ${guild.name}|${id}`) guild.members.fetch() logger.info(`Fetched: ${guild.memberCount} members`) }) } public async registerEventCallback() { try { const eventFiles = fs.readdirSync(this.eventFilePath).filter(file => file.endsWith('.ts') || file.endsWith('.js')); for (const file of eventFiles) { const filePath = `${this.eventFilePath}/${file}` const event = await this.importFile(filePath) if (event.once) { logger.info(`Registering once ${file}`) this.once(event.name, (...args: any[]) => event.execute(...args)) } else { logger.info(`Registering on ${file}`) this.on(event.name, (...args: any[]) => event.execute(...args)) } } logger.info(`Registered event names ${this.eventNames()}`) } catch (error) { logger.error(error) } } public async startAnnouncementRoleBackgroundTask(guilds: Collection) { for (const guild of guilds.values()) { logger.info("Starting background task for announcement role", { guildId: guild.id }) const textChannel: Maybe = this.getAnnouncementChannelForGuild(guild.id) if (!textChannel) { logger.error("Could not find announcement channel. Aborting", { guildId: guild.id }) return } this.announcementRoleHandlerTask.set(guild.id, schedule("*/10 * * * * *", async () => { const requestId = uuid() const messages = (await textChannel.messages.fetchPinned()).filter(message => isInitialAnnouncement(message)) if (messages.size > 1) { logger.error("More than one pinned announcement Messages found. Unable to know which one people react to. Please fix!", { guildId: guild.id, requestId }) return } else if (messages.size == 0) { logger.error("Could not find any pinned announcement messages. Unable to manage roles!", { guildId: guild.id, requestId }) return } const message = await messages.at(0)?.fetch() if (!message) { logger.error(`No pinned message found`, { guildId: guild.id, requestId }) return } //logger.debug(`Message: ${JSON.stringify(message, null, 2)}`, { guildId: guild.id, requestId }) const reactions = message.reactions.resolve("🎫") //logger.debug(`reactions: ${JSON.stringify(reactions, null, 2)}`, { guildId: guild.id, requestId }) if (reactions) { manageAnnouncementRoles(message.guild, reactions, requestId) } else { logger.error("Did not get reactions! Aborting!", { guildId: guild.id, requestId }) } })) } } public stopAnnouncementRoleBackgroundTask(guildId: string, requestId: string) { const task: Maybe = this.announcementRoleHandlerTask.get(guildId) if (!task) { logger.error(`No task found for guildID ${guildId}.`, { guildId, requestId }) return } task.stop() } private async startPollCloseBackgroundTasks() { for (const guild of this.guilds.cache) { this.pollCloseBackgroundTasks.set(guild[1].id, schedule("0 * * * * *", () => this.voteController.checkForPollsToClose(guild[1]))) } } }