diff --git a/src/__tests__/resolve-catch.helper.spec.ts b/src/__tests__/resolve-catch.helper.spec.ts index e2cef011..b4eab62d 100644 --- a/src/__tests__/resolve-catch.helper.spec.ts +++ b/src/__tests__/resolve-catch.helper.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { resolveCatch } from '../helpers/resolve-catch.helper'; +import { resolveCatch } from '../helpers/resolveCatch.helper'; describe('resolve-catch.helper', () => { it('should throw an error if the promise is rejected with an error', async () => { diff --git a/src/__tests__/summarize-cool-pages.spec.ts b/src/__tests__/summarize-cool-pages.spec.ts index c1345f70..8b37bb8e 100644 --- a/src/__tests__/summarize-cool-pages.spec.ts +++ b/src/__tests__/summarize-cool-pages.spec.ts @@ -5,7 +5,7 @@ import { isPageSummarizeSuccessData, NoContentFoundSummaryError, parseHtmlSummarized, -} from '../summarize-cool-pages'; +} from '../modules/coolLinksManagement/summarizeCoolPages'; const createSummarizeCoolPagesFixture = () => { return { // from https://react.dev/learn/you-might-not-need-an-effect diff --git a/src/commands.ts b/src/commands.ts deleted file mode 100644 index 1a72ad34..00000000 --- a/src/commands.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; - -export const voiceOnDemandCommand = new SlashCommandBuilder() - .setName('voice-on-demand') - .setDescription('Actions related to the voice lobby') - .addSubcommand((subcommand) => - subcommand.setName('create').setDescription('Creates the voice lobby'), - ) - .toJSON(); - -export const fartCommand = new SlashCommandBuilder() - .setName('fart') - .setDescription('Replies with https://prout.dev') - .toJSON(); diff --git a/src/cool-links-management.ts b/src/cool-links-management.ts deleted file mode 100644 index 871681d4..00000000 --- a/src/cool-links-management.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { type Message, ThreadAutoArchiveDuration } from 'discord.js'; -import ogs from 'open-graph-scraper'; - -import { isASocialNetworkUrl } from './helpers/regex.helper'; -import { getPageSummary } from './summarize-cool-pages'; -import { getVideoSummary } from './summarize-cool-videos'; - -const getThreadNameFromOpenGraph = async (url: string): Promise => { - try { - const { result } = await ogs({ url }); - if (!result.success) throw new Error('No OG data found'); - - const ogSiteName = result.ogSiteName; - const ogTitle = result.ogTitle; - if (ogSiteName && ogTitle) { - return `${ogSiteName} - ${ogTitle}`; - } - if (ogSiteName) { - return ogSiteName; - } - if (ogTitle) { - return ogTitle; - } - } catch (error) { - console.error(error); - } - - return null; -}; - -const youtubeUrlRegex = new RegExp('^(https?)?(://)?(www.)?(m.)?((youtube.com)|(youtu.be))'); - -export const coolLinksManagement = async (message: Message) => { - const urlRegex = /(((https?:\/\/)|(www\.))[^\s]+)/g; - const detectedURLs = message.content.match(urlRegex); - - if (detectedURLs === null) { - await message.delete(); - return; - } - - await message.react('✅'); - await message.react('❌'); - - const url = detectedURLs[0]; - const threadName = await getThreadNameFromOpenGraph(url); - const thread = await message.startThread({ - name: threadName ?? message.content, - autoArchiveDuration: ThreadAutoArchiveDuration.ThreeDays, - }); - if (thread.joinable) await thread.join(); - - if (youtubeUrlRegex.test(url)) { - const summary = await getVideoSummary(url); - if (!summary) return; - - await thread.send(summary); - } - if (!youtubeUrlRegex.test(url) && !isASocialNetworkUrl(url)) { - try { - const pageSummaryDiscordView = await getPageSummary(url); - await thread.send(pageSummaryDiscordView); - } catch (error) { - console.error(error); - } - } -}; diff --git a/src/helpers/cache.ts b/src/core/cache.ts similarity index 100% rename from src/helpers/cache.ts rename to src/core/cache.ts diff --git a/src/core/checkUniqueSlashCommandNames.ts b/src/core/checkUniqueSlashCommandNames.ts new file mode 100644 index 00000000..189647a5 --- /dev/null +++ b/src/core/checkUniqueSlashCommandNames.ts @@ -0,0 +1,11 @@ +import type { BotModule } from '../types/bot'; + +export const checkUniqueSlashCommandNames = (modulesToLoad: Record) => { + const slashCommandNames = Object.values(modulesToLoad) + .flatMap((module) => module.slashCommands ?? []) + .map((command) => command.schema.name); + const uniqueSlashCommandNames = new Set(slashCommandNames); + if (uniqueSlashCommandNames.size !== slashCommandNames.length) { + throw new Error('Found duplicate slash command names'); + } +}; diff --git a/src/delete-existing-commands.ts b/src/core/deleteExistingCommands.ts similarity index 93% rename from src/delete-existing-commands.ts rename to src/core/deleteExistingCommands.ts index b4d2bd36..bbac0b0e 100644 --- a/src/delete-existing-commands.ts +++ b/src/core/deleteExistingCommands.ts @@ -1,6 +1,6 @@ import { REST, Routes } from 'discord.js'; -import { config } from './config'; +import { config } from '../config'; export const deleteExistingCommands = async ( rest: REST, diff --git a/src/core/loadModules.ts b/src/core/loadModules.ts new file mode 100644 index 00000000..01009654 --- /dev/null +++ b/src/core/loadModules.ts @@ -0,0 +1,19 @@ +import { type Client } from 'discord.js'; + +import type { BotModule } from '../types/bot'; +import { checkUniqueSlashCommandNames } from './checkUniqueSlashCommandNames'; +import { pushCommands, routeCommands } from './loaderCommands'; +import { routeHandlers } from './routeHandlers'; + +export const loadModules = async ( + client: Client, + modulesToLoad: Record, +): Promise => { + const botCommands = Object.values(modulesToLoad).flatMap((module) => module.slashCommands ?? []); + + checkUniqueSlashCommandNames(modulesToLoad); + routeCommands(client, botCommands); + await pushCommands(botCommands.map((command) => command.schema)); + + routeHandlers(client, modulesToLoad); +}; diff --git a/src/core/loaderCommands.ts b/src/core/loaderCommands.ts new file mode 100644 index 00000000..57f18dd9 --- /dev/null +++ b/src/core/loaderCommands.ts @@ -0,0 +1,56 @@ +import { + Client, + REST, + type RESTPostAPIChatInputApplicationCommandsJSONBody, + Routes, +} from 'discord.js'; + +import { config } from '../config'; +import type { BotCommand } from '../types/bot'; +import { deleteExistingCommands } from './deleteExistingCommands'; + +const { discord } = config; + +export const pushCommands = async (commands: RESTPostAPIChatInputApplicationCommandsJSONBody[]) => { + const rest = new REST({ version: '10' }).setToken(discord.token); + await deleteExistingCommands(rest, discord); + await rest.put(Routes.applicationGuildCommands(discord.clientId, discord.guildId), { + body: commands, + }); +}; + +export const routeCommands = (client: Client, botCommands: BotCommand[]) => + client.on('interactionCreate', async (interaction) => { + if (!interaction.inGuild() || !interaction.isChatInputCommand()) { + return; + } + + const command = botCommands.find((command) => command.schema.name === interaction.commandName); + + if (!command) { + await interaction.reply({ + content: `Command not found ${interaction.commandName}`, + ephemeral: true, + }); + return; + } + + if (typeof command.handler === 'function') { + await command.handler(interaction); + return; + } + + const subCommand = command.handler[interaction.options.getSubcommand()]; + + if (!subCommand) { + await interaction.reply({ + content: `Subcommand not found ${ + interaction.commandName + } ${interaction.options.getSubcommand()}`, + ephemeral: true, + }); + return; + } + + await subCommand(interaction); + }); diff --git a/src/core/routeHandlers.ts b/src/core/routeHandlers.ts new file mode 100644 index 00000000..dc762b31 --- /dev/null +++ b/src/core/routeHandlers.ts @@ -0,0 +1,21 @@ +import type { Client, ClientEvents } from 'discord.js'; + +import type { BotModule, EventHandler } from '../types/bot'; + +export const routeHandlers = (client: Client, modulesToLoad: Record) => { + const eventNames = Object.values(modulesToLoad).flatMap( + (module) => Object.keys(module.eventHandlers ?? {}) as (keyof ClientEvents)[], + ); + const uniqueEventNames = [...new Set(eventNames)]; + + uniqueEventNames.forEach((eventName) => { + const eventHandlersToCall = Object.values(modulesToLoad) + .map((module) => module.eventHandlers?.[eventName]) + .filter((e): e is EventHandler => Boolean(e)); + + client.on(eventName, async (...args) => { + const handlersPromises = eventHandlersToCall.map(async (handler) => handler(...args)); + await Promise.allSettled(handlersPromises); + }); + }); +}; diff --git a/src/create-lobby.ts b/src/create-lobby.ts deleted file mode 100644 index ad001cbd..00000000 --- a/src/create-lobby.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Guild } from 'discord.js'; -import { ChannelType, ChatInputCommandInteraction } from 'discord.js'; - -import { cache } from './helpers/cache'; - -export const createLobby = async (interaction: ChatInputCommandInteraction): Promise => { - const guild = interaction.guild as Guild; - - const lobbyId = await cache.get('lobbyId'); - - if (lobbyId !== undefined && guild.channels.cache.has(lobbyId)) { - guild.channels.cache.delete(lobbyId); - } - - const channel = - lobbyId === undefined ? null : await guild.channels.fetch(lobbyId).catch(() => null); - - if (channel !== null) { - await interaction.reply({ - content: 'Voice on demand voice lobby already exists.', - ephemeral: true, - }); - - return; - } - - const { id } = await guild.channels.create({ - name: 'Lobby', - type: ChannelType.GuildVoice, - }); - - await cache.set('lobbyId', id); - - await interaction.reply({ - content: 'Created voice on demand voice channel.', - ephemeral: true, - }); -}; diff --git a/src/create-user-voice-channel.ts b/src/create-user-voice-channel.ts deleted file mode 100644 index 6c3d796e..00000000 --- a/src/create-user-voice-channel.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CategoryChannel, GuildChannelCreateOptions, GuildMember } from 'discord.js'; -import { ChannelType, OverwriteType } from 'discord.js'; - -import { cache } from './helpers/cache'; -import { normalizeName } from './utils/normalize-name'; - -export const createUserVoiceChannel = async ( - parent: CategoryChannel | null, - member: GuildMember, -): Promise => { - const { displayName, id, guild } = member; - - const name = `voice-${normalizeName(displayName)}`; - - const options: GuildChannelCreateOptions = { - name, - type: ChannelType.GuildVoice, - permissionOverwrites: [ - { type: OverwriteType.Member, id, allow: ['DeafenMembers', 'MuteMembers', 'MoveMembers'] }, - ], - }; - - const channel = await guild.channels.create(parent === null ? options : { ...options, parent }); - - const channels = await cache.get('channels', []); - - await cache.set('channels', [...channels, channel.id]); - - return channel.id; -}; diff --git a/src/handlers/handle-guild-message-creation.ts b/src/handlers/handle-guild-message-creation.ts deleted file mode 100644 index 253ec936..00000000 --- a/src/handlers/handle-guild-message-creation.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Message } from 'discord.js'; -import { MessageType } from 'discord.js'; - -import { config } from '../config'; -import { coolLinksManagement } from '../cool-links-management'; -import { patternReplacement } from '../pattern-replacement'; - -export const handleGuildMessageCreation = async (message: Message) => { - if (message.author.bot) { - return; - } - - if (message.type !== MessageType.Default) { - return; - } - - if (message.channelId === config.discord.coolLinksChannelId) { - await coolLinksManagement(message); - return; - } - - await patternReplacement(message); -}; diff --git a/src/handlers/handle-interaction-creation.ts b/src/handlers/handle-interaction-creation.ts deleted file mode 100644 index 21ac505d..00000000 --- a/src/handlers/handle-interaction-creation.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Interaction } from 'discord.js'; - -import { createLobby } from '../create-lobby'; - -export const handleInteractionCreation = async (interaction: Interaction): Promise => { - if ( - !interaction.isCommand() || - !interaction.inGuild() || - !interaction.isChatInputCommand() || - !['voice-on-demand', 'fart'].includes(interaction.commandName) - ) { - return; - } - - switch (interaction.commandName) { - case 'voice-on-demand': - if (interaction.options.getSubcommand(true) !== 'create') { - await interaction.reply('Unknown subcommand'); - return; - } - await createLobby(interaction); - break; - case 'fart': - await interaction.reply('https://prout.dev'); - break; - } -}; diff --git a/src/handlers/handle-voice-channel-deletion.ts b/src/handlers/handle-voice-channel-deletion.ts deleted file mode 100644 index 22e686e1..00000000 --- a/src/handlers/handle-voice-channel-deletion.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { DMChannel, NonThreadGuildBasedChannel } from 'discord.js'; -import { ChannelType } from 'discord.js'; - -import { cache } from '../helpers/cache'; - -export const handleVoiceChannelDeletion = async ( - channel: DMChannel | NonThreadGuildBasedChannel, -): Promise => { - if (channel.type !== ChannelType.GuildVoice) { - return; - } - - const lobbyId = await cache.get('lobbyId'); - - const { guild, id } = channel; - - if (id === lobbyId) { - await cache.delete('lobbyId'); - guild.channels.cache.delete(lobbyId); - - const channels = await cache.get('channels', []); - - await Promise.all( - channels.map(async (id) => { - const channel = await guild.channels.fetch(id).catch(() => null); - if (channel !== null) { - await guild.channels.delete(id); - guild.channels.cache.delete(id); - } - }), - ); - } -}; diff --git a/src/handlers/handle-voice-state-update.ts b/src/handlers/handle-voice-state-update.ts deleted file mode 100644 index 9be340c1..00000000 --- a/src/handlers/handle-voice-state-update.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { VoiceState } from 'discord.js'; -import type { SetNonNullable } from 'type-fest'; - -import { createUserVoiceChannel } from '../create-user-voice-channel'; -import { cache } from '../helpers/cache'; - -type CheckedVoiceState = SetNonNullable; - -const isJoinState = (newState: VoiceState): newState is CheckedVoiceState => - newState.channel !== null && newState.channelId !== null && newState.member !== null; - -const isLeaveState = (oldDate: VoiceState): oldDate is CheckedVoiceState => - oldDate.channel !== null && oldDate.channelId !== null && oldDate.member !== null; - -const handleJoin = async (state: CheckedVoiceState, lobbyId: string): Promise => { - if (state.channelId !== lobbyId) { - return; - } - - const channel = await createUserVoiceChannel(state.channel.parent, state.member); - await state.member.voice.setChannel(channel); -}; - -const handleLeave = async (state: CheckedVoiceState): Promise => { - const channels = await cache.get('channels', []); - - const { channel } = state; - const { id, members, guild } = channel; - - if (channels.includes(id) && members.size === 0) { - await channel.delete(); - guild.channels.cache.delete(id); - - const filtered = channels.filter((channelId) => channelId !== id); - await cache.set('channels', filtered); - } -}; - -export const handleVoiceStateUpdate = async ( - oldState: VoiceState, - newState: VoiceState, -): Promise => { - const lobbyId = await cache.get('lobbyId'); - if (lobbyId === undefined) { - return; - } - - if (isLeaveState(oldState)) { - await handleLeave(oldState); - } - - if (isJoinState(newState)) { - await handleJoin(newState, lobbyId); - } -}; diff --git a/src/utils/normalize-name.ts b/src/helpers/normalizeName.helper.ts similarity index 100% rename from src/utils/normalize-name.ts rename to src/helpers/normalizeName.helper.ts diff --git a/src/helpers/resolve-catch.helper.ts b/src/helpers/resolveCatch.helper.ts similarity index 100% rename from src/helpers/resolve-catch.helper.ts rename to src/helpers/resolveCatch.helper.ts diff --git a/src/main.ts b/src/main.ts index 15b29a01..a9eb77d2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,57 +1,25 @@ -import { Client, REST, Routes } from 'discord.js'; +import { Client } from 'discord.js'; -import { fartCommand, voiceOnDemandCommand } from './commands'; import { config } from './config'; -import { deleteExistingCommands } from './delete-existing-commands'; -import { handleGuildMessageCreation } from './handlers/handle-guild-message-creation'; -import { handleInteractionCreation } from './handlers/handle-interaction-creation'; -import { handleVoiceChannelDeletion } from './handlers/handle-voice-channel-deletion'; -import { handleVoiceStateUpdate } from './handlers/handle-voice-state-update'; +import { loadModules } from './core/loadModules'; +import { modules } from './modules/modules'; const { discord } = config; - -const bootstrap = async (client: Client) => { - await client.login(discord.token); - - await new Promise((resolve) => { - client.on('ready', () => { - resolve(); - }); - }); - - if (!client.isReady()) { - throw new Error('Client should be ready at this stage'); - } -}; - const client = new Client({ intents: ['Guilds', 'GuildVoiceStates', 'GuildMembers', 'GuildMessages', 'MessageContent'], }); -await bootstrap(client); - -client.on('channelDelete', async (channel) => { - await handleVoiceChannelDeletion(channel); -}); - -client.on('voiceStateUpdate', async (oldState, newState) => { - await handleVoiceStateUpdate(oldState, newState); -}); - -client.on('interactionCreate', async (interaction) => { - await handleInteractionCreation(interaction); -}); - -client.on('messageCreate', async (message) => { - await handleGuildMessageCreation(message); +await client.login(discord.token); +await new Promise((resolve) => { + client.on('ready', () => { + resolve(); + }); }); -const rest = new REST({ version: '10' }).setToken(discord.token); - -await deleteExistingCommands(rest, discord); +if (!client.isReady()) { + throw new Error('Client should be ready at this stage'); +} -await rest.put(Routes.applicationGuildCommands(discord.clientId, discord.guildId), { - body: [voiceOnDemandCommand, fartCommand], -}); +await loadModules(client, modules); console.log('Bot started.'); diff --git a/src/modules/coolLinksManagement/coolLinksManagement.module.ts b/src/modules/coolLinksManagement/coolLinksManagement.module.ts new file mode 100644 index 00000000..e4cedb58 --- /dev/null +++ b/src/modules/coolLinksManagement/coolLinksManagement.module.ts @@ -0,0 +1,80 @@ +import { MessageType, ThreadAutoArchiveDuration } from 'discord.js'; +import ogs from 'open-graph-scraper'; + +import { config } from '../../config'; +import { isASocialNetworkUrl } from '../../helpers/regex.helper'; +import type { BotModule } from '../../types/bot'; +import { getPageSummary } from './summarizeCoolPages'; +import { getVideoSummary } from './summarizeCoolVideos'; + +const getThreadNameFromOpenGraph = async (url: string): Promise => { + try { + const { result } = await ogs({ url }); + if (!result.success) throw new Error('No OG data found'); + + const ogSiteName = result.ogSiteName; + const ogTitle = result.ogTitle; + if (ogSiteName && ogTitle) { + return `${ogSiteName} - ${ogTitle}`; + } + if (ogSiteName) { + return ogSiteName; + } + if (ogTitle) { + return ogTitle; + } + } catch (error) { + console.error(error); + } + + return null; +}; + +const youtubeUrlRegex = new RegExp('^(https?)?(://)?(www.)?(m.)?((youtube.com)|(youtu.be))'); + +export const coolLinksManagement: BotModule = { + eventHandlers: { + messageCreate: async (message) => { + if ( + message.author.bot || + message.type !== MessageType.Default || + message.channelId !== config.discord.coolLinksChannelId + ) { + return; + } + const urlRegex = /(((https?:\/\/)|(www\.))[^\s]+)/g; + const detectedURLs = message.content.match(urlRegex); + + if (detectedURLs === null) { + await message.delete(); + return; + } + + await message.react('✅'); + await message.react('❌'); + + const url = detectedURLs[0]; + const threadName = await getThreadNameFromOpenGraph(url); + const thread = await message.startThread({ + name: threadName ?? message.content, + autoArchiveDuration: ThreadAutoArchiveDuration.ThreeDays, + }); + if (thread.joinable) await thread.join(); + + if (youtubeUrlRegex.test(url)) { + const summary = await getVideoSummary(url); + if (!summary) return; + + await thread.send(summary); + } + if (!youtubeUrlRegex.test(url) && !isASocialNetworkUrl(url)) { + try { + const pageSummaryDiscordView = await getPageSummary(url); + await thread.send(pageSummaryDiscordView); + } catch (error) { + console.error(error); + } + } + }, + }, +}; diff --git a/src/summarize-cool-pages.ts b/src/modules/coolLinksManagement/summarizeCoolPages.ts similarity index 96% rename from src/summarize-cool-pages.ts rename to src/modules/coolLinksManagement/summarizeCoolPages.ts index 4d9148a8..f10399d4 100644 --- a/src/summarize-cool-pages.ts +++ b/src/modules/coolLinksManagement/summarizeCoolPages.ts @@ -1,7 +1,7 @@ import { load } from 'cheerio'; -import { config } from './config'; -import { resolveCatch } from './helpers/resolve-catch.helper'; +import { config } from '../../config'; +import { resolveCatch } from '../../helpers/resolveCatch.helper'; // langfrom can't be changed to another language, this result in a translation of the summary that throw an HTTP error because we are in "FREE PLAN" const parseBaseUrl = `${config.thirdParties.pageSummarizerBaseUrl}/convert.php?type=expand&lang=en&langfrom=user&url=`; diff --git a/src/summarize-cool-videos.ts b/src/modules/coolLinksManagement/summarizeCoolVideos.ts similarity index 100% rename from src/summarize-cool-videos.ts rename to src/modules/coolLinksManagement/summarizeCoolVideos.ts diff --git a/src/modules/fart/fart.module.ts b/src/modules/fart/fart.module.ts new file mode 100644 index 00000000..ff024a37 --- /dev/null +++ b/src/modules/fart/fart.module.ts @@ -0,0 +1,17 @@ +import { SlashCommandBuilder } from 'discord.js'; + +import type { BotModule } from '../../types/bot'; + +export const fart: BotModule = { + slashCommands: [ + { + schema: new SlashCommandBuilder() + .setName('fart') + .setDescription('Replies with https://prout.dev') + .toJSON(), + handler: async (interaction) => { + await interaction.reply('https://prout.dev'); + }, + }, + ], +}; diff --git a/src/modules/modules.ts b/src/modules/modules.ts new file mode 100644 index 00000000..4e6bdc8d --- /dev/null +++ b/src/modules/modules.ts @@ -0,0 +1,11 @@ +import { coolLinksManagement } from './coolLinksManagement/coolLinksManagement.module'; +import { fart } from './fart/fart.module'; +import { patternReplace } from './patternReplace/patternReplace.module'; +import { voiceOnDemand } from './voiceOnDemand/voiceOnDemand.module'; + +export const modules = { + fart, + voiceOnDemand, + coolLinksManagement, + patternReplace, +}; diff --git a/src/modules/patternReplace/patternReplace.module.ts b/src/modules/patternReplace/patternReplace.module.ts new file mode 100644 index 00000000..9a313e5e --- /dev/null +++ b/src/modules/patternReplace/patternReplace.module.ts @@ -0,0 +1,41 @@ +import { MessageType } from 'discord.js'; + +import { config } from '../../config'; +import type { BotModule } from '../../types/bot'; + +const urlMappings = [ + { + pattern: /https?:\/\/(mobile\.)?twitter\.com\/(\S+)\/status\/(\d+)/g, + replacement: 'https://vxtwitter.com/$2/status/$3', + }, +]; + +export const patternReplace: BotModule = { + eventHandlers: { + messageCreate: async (message) => { + if ( + message.author.bot || + message.type !== MessageType.Default || + message.channelId === config.discord.coolLinksChannelId + ) { + return; + } + + let modifiedContent = message.content; + + for (const { pattern, replacement } of urlMappings) { + if (pattern.test(modifiedContent)) { + modifiedContent = modifiedContent.replace(pattern, replacement); + } + } + + const hasModification = message.content !== modifiedContent; + if (!hasModification) return; + + const newMessage = [`<@${message.author.id}>`, modifiedContent].join('\n'); + + await message.channel.send(newMessage); + await message.delete(); + }, + }, +}; diff --git a/src/modules/voiceOnDemand/voiceOnDemand.helpers.ts b/src/modules/voiceOnDemand/voiceOnDemand.helpers.ts new file mode 100644 index 00000000..45001523 --- /dev/null +++ b/src/modules/voiceOnDemand/voiceOnDemand.helpers.ts @@ -0,0 +1,68 @@ +import type { + CategoryChannel, + GuildChannelCreateOptions, + GuildMember, + VoiceState, +} from 'discord.js'; +import { ChannelType, OverwriteType } from 'discord.js'; +import type { SetNonNullable } from 'type-fest'; + +import { cache } from '../../core/cache'; +import { normalizeName } from '../../helpers/normalizeName.helper'; + +type CheckedVoiceState = SetNonNullable; + +export const isJoinState = (newState: VoiceState): newState is CheckedVoiceState => + newState.channel !== null && newState.channelId !== null && newState.member !== null; + +export const isLeaveState = (oldDate: VoiceState): oldDate is CheckedVoiceState => + oldDate.channel !== null && oldDate.channelId !== null && oldDate.member !== null; + +export const handleJoin = async (state: CheckedVoiceState, lobbyId: string): Promise => { + if (state.channelId !== lobbyId) { + return; + } + + const channel = await createUserVoiceChannel(state.channel.parent, state.member); + await state.member.voice.setChannel(channel); +}; + +export const handleLeave = async (state: CheckedVoiceState): Promise => { + const channels = await cache.get('channels', []); + + const { channel } = state; + const { id, members, guild } = channel; + + if (channels.includes(id) && members.size === 0) { + await channel.delete(); + guild.channels.cache.delete(id); + + const filtered = channels.filter((channelId) => channelId !== id); + await cache.set('channels', filtered); + } +}; + +export const createUserVoiceChannel = async ( + parent: CategoryChannel | null, + member: GuildMember, +): Promise => { + const { displayName, id, guild } = member; + + const name = `voice-${normalizeName(displayName)}`; + + const options: GuildChannelCreateOptions = { + name, + type: ChannelType.GuildVoice, + permissionOverwrites: [ + { type: OverwriteType.Member, id, allow: ['DeafenMembers', 'MuteMembers', 'MoveMembers'] }, + ], + }; + + const channel = await guild.channels.create(parent === null ? options : { ...options, parent }); + + const channels = await cache.get('channels', []); + + await cache.set('channels', [...channels, channel.id]); + + return channel.id; +}; diff --git a/src/modules/voiceOnDemand/voiceOnDemand.module.ts b/src/modules/voiceOnDemand/voiceOnDemand.module.ts new file mode 100644 index 00000000..532c4d77 --- /dev/null +++ b/src/modules/voiceOnDemand/voiceOnDemand.module.ts @@ -0,0 +1,96 @@ +import { ChannelType, Guild, SlashCommandBuilder } from 'discord.js'; + +import { cache } from '../../core/cache'; +import type { BotModule } from '../../types/bot'; +import { handleJoin, handleLeave, isJoinState, isLeaveState } from './voiceOnDemand.helpers'; + +export const voiceOnDemand: BotModule = { + slashCommands: [ + { + schema: new SlashCommandBuilder() + .setName('voice-on-demand') + .setDescription('Actions related to the voice lobby') + .addSubcommand((subcommand) => + subcommand.setName('create').setDescription('Creates the voice lobby'), + ) + .toJSON(), + handler: { + create: async (interaction): Promise => { + const guild = interaction.guild as Guild; + + const lobbyId = await cache.get('lobbyId'); + + if (lobbyId !== undefined && guild.channels.cache.has(lobbyId)) { + guild.channels.cache.delete(lobbyId); + } + + const channel = + lobbyId === undefined ? null : await guild.channels.fetch(lobbyId).catch(() => null); + + if (channel !== null) { + await interaction.reply({ + content: 'Voice on demand voice lobby already exists.', + ephemeral: true, + }); + + return; + } + + const { id } = await guild.channels.create({ + name: 'Lobby', + type: ChannelType.GuildVoice, + }); + + await cache.set('lobbyId', id); + + await interaction.reply({ + content: 'Created voice on demand voice channel.', + ephemeral: true, + }); + }, + }, + }, + ], + eventHandlers: { + voiceStateUpdate: async (oldState, newState) => { + const lobbyId = await cache.get('lobbyId'); + if (lobbyId === undefined) { + return; + } + + if (isLeaveState(oldState)) { + await handleLeave(oldState); + } + + if (isJoinState(newState)) { + await handleJoin(newState, lobbyId); + } + }, + channelDelete: async (channel) => { + if (channel.type !== ChannelType.GuildVoice) { + return; + } + + const lobbyId = await cache.get('lobbyId'); + + const { guild, id } = channel; + + if (id === lobbyId) { + await cache.delete('lobbyId'); + guild.channels.cache.delete(lobbyId); + + const channels = await cache.get('channels', []); + + await Promise.all( + channels.map(async (id) => { + const channel = await guild.channels.fetch(id).catch(() => null); + if (channel !== null) { + await guild.channels.delete(id); + guild.channels.cache.delete(id); + } + }), + ); + } + }, + }, +}; diff --git a/src/pattern-replacement.ts b/src/pattern-replacement.ts deleted file mode 100644 index c50515e2..00000000 --- a/src/pattern-replacement.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Message } from 'discord.js'; - -const urlMappings = [ - { - pattern: /https?:\/\/(mobile\.)?twitter\.com\/(\S+)\/status\/(\d+)/g, - replacement: 'https://vxtwitter.com/$2/status/$3', - }, -]; - -export const patternReplacement = async (message: Message) => { - let modifiedContent = message.content; - - for (const { pattern, replacement } of urlMappings) { - if (pattern.test(modifiedContent)) { - modifiedContent = modifiedContent.replace(pattern, replacement); - } - } - - const hasModification = message.content !== modifiedContent; - if (!hasModification) return; - - const newMessage = [`<@${message.author.id}>`, modifiedContent].join('\n'); - - await message.channel.send(newMessage); - await message.delete(); -}; diff --git a/src/types/bot.ts b/src/types/bot.ts new file mode 100644 index 00000000..a26a2e57 --- /dev/null +++ b/src/types/bot.ts @@ -0,0 +1,25 @@ +import type { + ChatInputCommandInteraction, + ClientEvents, + RESTPostAPIChatInputApplicationCommandsJSONBody, +} from 'discord.js'; + +type slashCommandHandler = ( + interaction: ChatInputCommandInteraction<'cached' | 'raw'>, +) => Promise; + +export type EventHandler = ( + ...args: ClientEvents[T] +) => Promise; + +export type BotCommand = { + schema: RESTPostAPIChatInputApplicationCommandsJSONBody; + handler: slashCommandHandler | Record; +}; + +export type BotModule = { + slashCommands?: Array; + eventHandlers?: { + [key in keyof ClientEvents]?: EventHandler; + }; +};