diff --git a/src/core/cache.ts b/src/core/cache.ts index f84ecadf..33c28e61 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -24,6 +24,12 @@ interface CacheEntries { lobbyIds: string[]; onDemandChannels: string[]; quoiFeurChannels: string[]; + cookieHunterChannels: string[]; + currentHuntMessageId: string; + cookieHunterDailyCount: Record; + cookieHunterDailyLogChannels: string[]; + cookieHunterScoreboard: Record; + milkJokerUserId: string; recurringMessages: { id: string; channelId: string; frequency: Frequency; message: string }[]; } @@ -65,3 +71,4 @@ class CacheImpl implements Cache { } export const cache = new CacheImpl(); +export type { CacheEntries }; diff --git a/src/helpers/channels.ts b/src/helpers/channels.ts new file mode 100644 index 00000000..3045b3bb --- /dev/null +++ b/src/helpers/channels.ts @@ -0,0 +1,74 @@ +import { + ChannelType, + type ChatInputCommandInteraction, + type DMChannel, + type NonThreadGuildBasedChannel, +} from 'discord.js'; + +import { cache, type CacheEntries } from '../core/cache'; + +type ChannelArrayCacheKey = Pick< + CacheEntries, + 'quoiFeurChannels' | 'cookieHunterChannels' | 'cookieHunterDailyLogChannels' +>; + +type FeatureName = 'Cookie Hunter' | 'Cookie Hunter Daily logs' | 'Quoi-Feur'; + +export const addChannelInCache = async ( + interaction: ChatInputCommandInteraction, + featureName: FeatureName, + cacheKey: keyof ChannelArrayCacheKey, +) => { + const { channel } = interaction; + if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) return; + + const channels = await cache.get(cacheKey, []); + if (channels.includes(channel.id)) { + await interaction.reply({ + content: `${featureName} is already enabled in this channel`, + ephemeral: true, + }); + return; + } + + await cache.set(cacheKey, [...channels, channel.id]); + await interaction.reply({ content: `${featureName} enabled in this channel`, ephemeral: true }); +}; + +export const removeChannelFromChache = async ( + interaction: ChatInputCommandInteraction, + featureName: FeatureName, + cacheKey: keyof ChannelArrayCacheKey, +) => { + const { channel } = interaction; + if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) return; + + const channels = await cache.get(cacheKey, []); + if (!channels.includes(channel.id)) { + await interaction.reply({ + content: `${featureName} is not enabled in this channel`, + ephemeral: true, + }); + return; + } + + await cache.set( + cacheKey, + channels.filter((channelId) => channelId !== channel.id), + ); + await interaction.reply({ content: `${featureName} disabled in this channel`, ephemeral: true }); +}; + +export const cleanCacheOnChannelDelete = async ( + channel: DMChannel | NonThreadGuildBasedChannel, + cacheKey: keyof ChannelArrayCacheKey, +) => { + const { id } = channel; + const channels = await cache.get(cacheKey, []); + if (!channels.includes(id)) return; + + await cache.set( + cacheKey, + channels.filter((channelId) => channelId !== id), + ); +}; diff --git a/src/helpers/timeConstants.ts b/src/helpers/timeConstants.ts new file mode 100644 index 00000000..abf5ee18 --- /dev/null +++ b/src/helpers/timeConstants.ts @@ -0,0 +1 @@ +export const ONE_MINUTE = 1 * 60 * 1000; diff --git a/src/main.ts b/src/main.ts index 38851607..76b0105d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { createAllModules } from './core/createEnvForModule'; import { env } from './core/env'; import { getIntentsFromModules } from './core/getIntentsFromModules'; import { loadModules } from './core/loadModules'; +import { cookieHunter } from './modules/cookieHunter/cookieHunter.module'; import { coolLinksManagement } from './modules/coolLinksManagement/coolLinksManagement.module'; import { fart } from './modules/fart/fart.module'; import { fixEmbedTwitterVideo } from './modules/fixEmbedTwitterVideo/fixEmbedTwitterVideo.module'; @@ -18,6 +19,7 @@ const modules = [ quoiFeur, recurringMessage, fixEmbedTwitterVideo, + cookieHunter, ]; const createdModules = await createAllModules(modules); diff --git a/src/modules/cookieHunter/cookieHunter.helpers.ts b/src/modules/cookieHunter/cookieHunter.helpers.ts new file mode 100644 index 00000000..8cf0615e --- /dev/null +++ b/src/modules/cookieHunter/cookieHunter.helpers.ts @@ -0,0 +1,193 @@ +import { CronJob } from 'cron'; +import type { + ChatInputCommandInteraction, + Client, + Message, + MessageReaction, + PartialMessageReaction, + PartialUser, + User, +} from 'discord.js'; + +import { cache } from '../../core/cache'; +import { ONE_MINUTE } from '../../helpers/timeConstants'; + +const IT_IS_SNACK_TIME = '0 30 16 * * *'; // 4:30pm every day + +let jobCurrentlyRunning: CronJob | null = null; + +const sendMessageInRandomChannel = async (client: Client) => { + const channel = await cache.get('cookieHunterChannels', []); + if (!channel.length) return; + + const randomChannel = channel[Math.floor(Math.random() * channel.length)]; + if (!randomChannel) return; + + const channelToSend = await client.channels.fetch(randomChannel); + + if (!channelToSend || !channelToSend.isTextBased()) return; + const cookieMessage = await channelToSend.send('**👵 Qui veut des cookies ?**'); + await Promise.all([ + cache.set('currentHuntMessageId', cookieMessage.id), + cache.set('cookieHunterDailyCount', {}), + cookieMessage.react('🥛'), + cookieMessage.react('🍪'), // 1 point for grandma here, she beats everyone who doesn't find her + ]); + setTimeout(() => void dailyHuntEnd(client, cookieMessage), ONE_MINUTE); +}; + +const handleMilkReaction = async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + isMilkJokerAlreadyFound: boolean, +) => { + if (isMilkJokerAlreadyFound) { + await reaction.message.reply({ + content: `Il est lent ce lait... 🥛`, + options: { ephemeral: true }, + }); + } else { + await cache.set('milkJokerUserId', user.id); + await reaction.message.reply({ + content: `Premier arrivé, premier servit. Cul sec 🥛 !`, + options: { ephemeral: true }, + }); + } +}; + +const applyMilkJoker = async () => { + const milkJokerUserId = await cache.get('milkJokerUserId'); + if (!milkJokerUserId) return; + + const cookieHunterDailyCount = await cache.get('cookieHunterDailyCount', {}); + const userDailyCount = cookieHunterDailyCount[milkJokerUserId] || 0; + const newDailyCount = { ...cookieHunterDailyCount, [milkJokerUserId]: userDailyCount * 2 }; + await cache.set('cookieHunterDailyCount', newDailyCount); +}; + +const logDailyCount = async (client: Client) => { + const dailyLogChannels = await cache.get('cookieHunterDailyLogChannels', []); + if (!dailyLogChannels.length) return; + + const currentHuntMessageId = await cache.get('currentHuntMessageId'); + if (!currentHuntMessageId) + throw new Error('Lost the hunt message id before logging the daily count'); + + const cookieHunterDailyCount = await cache.get('cookieHunterDailyCount', {}); + const hunterCount = Object.keys(cookieHunterDailyCount).length - 1; // grandma is not a hunter + + const milkJokerUserId = await cache.get('milkJokerUserId'); + + const resume = `**🍪 Résumé de la chasse aux cookies du jour**\n`; + const where = `Mamie a servi des cookies dans <#${currentHuntMessageId}>\n`; + const baseMessage = `${resume}${where}`; + + const message = + hunterCount > 0 + ? getHuntersFoundGrandmaMessage(baseMessage, cookieHunterDailyCount, milkJokerUserId) + : `${baseMessage}**🍪 Personne n'a trouvé Mamie !**\nFaut dire qu'elle se cache bien (et que vous êtes nazes) !`; + + for (const channelId of dailyLogChannels) { + const channel = await client.channels.fetch(channelId); + if (!channel || !channel.isTextBased()) continue; + await channel.send(message); + } +}; + +const getHuntersFoundGrandmaMessage = ( + baseMessage: string, + cookieHunterDailyCount: Record, + milkJokerUserId: string | undefined, +) => { + const cookieEatenCount = Object.values(cookieHunterDailyCount).reduce( + (acc, count) => acc + count, + 0, + ); + const dailyRank = Object.entries(cookieHunterDailyCount).sort((a, b) => b[1] - a[1]); + + return [ + baseMessage, + `Nombre de cookies total mangés : ${cookieEatenCount}`, + `**Classement des chasseurs de cookies du jour**`, + dailyRank.map(([userId, count]) => `<@${userId}>: ${count}`).join('\n'), + milkJokerUserId + ? `<@${milkJokerUserId}> a accompagné ses cookies d'un grand verre de lait 🥛` + : null, + `Sacré bande de gourmands !`, + ] + .filter(Boolean) + .join('\n'); +}; + +const updateGlobalScoreboard = async () => { + const cookieHunterDailyCount = await cache.get('cookieHunterDailyCount', {}); + const coockieHunterScoreboard = await cache.get('cookieHunterScoreboard', {}); + for (const [userId, count] of Object.entries(cookieHunterDailyCount)) { + coockieHunterScoreboard[userId] = (coockieHunterScoreboard[userId] || 0) + count; + } + await cache.set('cookieHunterScoreboard', coockieHunterScoreboard); +}; + +const dailyHuntEnd = async (client: Client, cookieMessage: Message) => { + await cookieMessage.delete(); + await applyMilkJoker(); + await logDailyCount(client); + await updateGlobalScoreboard(); + await Promise.all([ + cache.delete('milkJokerUserId'), + cache.delete('currentHuntMessageId'), + cache.delete('cookieHunterDailyCount'), + ]); +}; + +export const startHunting = (client: Client) => { + console.log('Cookie hunter started'); + if (jobCurrentlyRunning !== null) { + // needed in case that the bot fire multiple ready event + jobCurrentlyRunning.stop(); + } + jobCurrentlyRunning = new CronJob( + IT_IS_SNACK_TIME, + () => sendMessageInRandomChannel(client), + null, + true, + 'Europe/Paris', + ); +}; + +export const countCookies = async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, +) => { + const currentHuntMessageId = await cache.get('currentHuntMessageId'); + if ( + !currentHuntMessageId || + reaction.message.id !== currentHuntMessageId || + reaction.emoji.name === null || + !['🍪', '🥛'].includes(reaction.emoji.name) + ) + return; + + const isMilkJokerAlreadyFound = Boolean(await cache.get('milkJokerUserId')); + + if (reaction.emoji.name === '🥛' && !user.bot) { + await handleMilkReaction(reaction, user, isMilkJokerAlreadyFound); + } + + const cookieHunterDailyCount = await cache.get('cookieHunterDailyCount', {}); + const userDailyCount = cookieHunterDailyCount[user.id] || 0; + const newDailyCount = { ...cookieHunterDailyCount, [user.id]: userDailyCount + 1 }; + await cache.set('cookieHunterDailyCount', newDailyCount); +}; + +export const displayScoreboard = async (interaction: ChatInputCommandInteraction) => { + const cookieHunterScoreboard = await cache.get('cookieHunterScoreboard', {}); + const ranking = Object.entries(cookieHunterScoreboard) + .sort((a, b) => b[1] - a[1]) + .map(([userId, count], index) => `${index + 1}. <@${userId}>: ${count}`) + .join('\n'); + + const message = `**🍪 Classement général des chasseurs de cookies**\n${ranking}`; + + await interaction.reply(message); +}; diff --git a/src/modules/cookieHunter/cookieHunter.module.ts b/src/modules/cookieHunter/cookieHunter.module.ts new file mode 100644 index 00000000..ca434cac --- /dev/null +++ b/src/modules/cookieHunter/cookieHunter.module.ts @@ -0,0 +1,71 @@ +import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; + +import { createModule } from '../../core/createModule'; +import { + addChannelInCache, + cleanCacheOnChannelDelete, + removeChannelFromChache, +} from '../../helpers/channels'; +import { countCookies, displayScoreboard, startHunting } from './cookieHunter.helpers'; + +export const cookieHunter = createModule({ + name: 'cookieHunter', + slashCommands: () => [ + { + schema: new SlashCommandBuilder() + .setName('cookie-hunter') + .setDescription('Cookie hunting game for the server') + .addSubcommand((subcommand) => + subcommand.setName('start').setDescription('Start the cookie hunt'), + ) + .addSubcommand((subcommand) => + subcommand.setName('enable').setDescription('Enable the cookie hunt in the channel'), + ) + .addSubcommand((subcommand) => + subcommand.setName('disable').setDescription('Disable the cookie hunt in the channel'), + ) + .addSubcommand((subcommand) => + subcommand.setName('add-daily-log').setDescription('Add daily log to the channel'), + ) + .addSubcommand((subcommand) => + subcommand.setName('remove-daily-log').setDescription('Add daily log to the channel'), + ) + .addSubcommand((subcommand) => + subcommand.setName('scoreboard').setDescription('Show the scoreboard'), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .toJSON(), + handler: { + // eslint-disable-next-line @typescript-eslint/require-await + 'start': async (interaction) => startHunting(interaction.client), + 'enable': (interaction) => + addChannelInCache(interaction, 'Cookie Hunter', 'cookieHunterChannels'), + 'disable': (interaction) => + removeChannelFromChache(interaction, 'Cookie Hunter', 'cookieHunterChannels'), + 'add-daily-log': (interaction) => + addChannelInCache( + interaction, + 'Cookie Hunter Daily logs', + 'cookieHunterDailyLogChannels', + ), + 'remove-daily-log': (interaction) => + removeChannelFromChache( + interaction, + 'Cookie Hunter Daily logs', + 'cookieHunterDailyLogChannels', + ), + 'scoreboard': async (interaction) => displayScoreboard(interaction), + }, + }, + ], + eventHandlers: () => ({ + // eslint-disable-next-line @typescript-eslint/require-await + ready: async (client) => startHunting(client), + messageReactionAdd: countCookies, + channelDelete: async (channel) => { + await cleanCacheOnChannelDelete(channel, 'cookieHunterChannels'); + await cleanCacheOnChannelDelete(channel, 'cookieHunterDailyLogChannels'); + }, + }), + intents: ['Guilds'], +}); diff --git a/src/modules/quoiFeur/quoiFeur.helpers.ts b/src/modules/quoiFeur/quoiFeur.helpers.ts index 417c7bbc..f3906bc1 100644 --- a/src/modules/quoiFeur/quoiFeur.helpers.ts +++ b/src/modules/quoiFeur/quoiFeur.helpers.ts @@ -1,10 +1,4 @@ -import { - ChannelType, - type ChatInputCommandInteraction, - DMChannel, - type Message, - type NonThreadGuildBasedChannel, -} from 'discord.js'; +import { ChannelType, type Message } from 'discord.js'; import { cache } from '../../core/cache'; import type { Emoji } from '../../helpers/emoji'; @@ -15,8 +9,7 @@ import { removeNonASCII, removePunctuation, } from '../../helpers/regex.helper'; - -const ONE_MINUTE = 1 * 60 * 1000; +import { ONE_MINUTE } from '../../helpers/timeConstants'; const quoiDetectorRegex = /\bquoi\s*$/i; const endWithQuoi = (text: string) => @@ -61,53 +54,3 @@ export const reactOnEndWithQuoi = async (message: Message) => { await reactWithFeur(message); }; - -export const addQuoiFeurToChannel = async (interaction: ChatInputCommandInteraction) => { - const { channel } = interaction; - if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) return; - - const channels = await cache.get('quoiFeurChannels', []); - if (channels.includes(channel.id)) { - await interaction.reply({ - content: 'Quoi-feur is already enabled in this channel', - ephemeral: true, - }); - return; - } - - await cache.set('quoiFeurChannels', [...channels, channel.id]); - await interaction.reply({ content: 'Quoi-feur enabled in this channel', ephemeral: true }); -}; - -export const removeQuoiFeurFromChannel = async (interaction: ChatInputCommandInteraction) => { - const { channel } = interaction; - if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) return; - - const channels = await cache.get('quoiFeurChannels', []); - if (!channels.includes(channel.id)) { - await interaction.reply({ - content: 'Quoi-feur is not enabled in this channel', - ephemeral: true, - }); - return; - } - - await cache.set( - 'quoiFeurChannels', - channels.filter((channelId) => channelId !== channel.id), - ); - await interaction.reply({ content: 'Quoi-feur disabled in this channel', ephemeral: true }); -}; - -export const cleanCacheOnChannelDelete = async ( - channel: DMChannel | NonThreadGuildBasedChannel, -) => { - const { id } = channel; - const channels = await cache.get('quoiFeurChannels', []); - if (!channels.includes(id)) return; - - await cache.set( - 'quoiFeurChannels', - channels.filter((channelId) => channelId !== id), - ); -}; diff --git a/src/modules/quoiFeur/quoiFeur.module.ts b/src/modules/quoiFeur/quoiFeur.module.ts index 4274f3ad..d9e28044 100644 --- a/src/modules/quoiFeur/quoiFeur.module.ts +++ b/src/modules/quoiFeur/quoiFeur.module.ts @@ -2,11 +2,11 @@ import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { createModule } from '../../core/createModule'; import { - addQuoiFeurToChannel, + addChannelInCache, cleanCacheOnChannelDelete, - reactOnEndWithQuoi, - removeQuoiFeurFromChannel, -} from './quoiFeur.helpers'; + removeChannelFromChache, +} from '../../helpers/channels'; +import { reactOnEndWithQuoi } from './quoiFeur.helpers'; export const quoiFeur = createModule({ name: 'quoiFeur', @@ -24,14 +24,15 @@ export const quoiFeur = createModule({ .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) .toJSON(), handler: { - enable: addQuoiFeurToChannel, - disable: removeQuoiFeurFromChannel, + enable: (interaction) => addChannelInCache(interaction, 'Quoi-Feur', 'quoiFeurChannels'), + disable: (interaction) => + removeChannelFromChache(interaction, 'Quoi-Feur', 'quoiFeurChannels'), }, }, ], eventHandlers: () => ({ messageCreate: reactOnEndWithQuoi, - channelDelete: cleanCacheOnChannelDelete, + channelDelete: (channel) => cleanCacheOnChannelDelete(channel, 'quoiFeurChannels'), }), intents: ['Guilds', 'GuildMessages', 'MessageContent', 'GuildMessageReactions'], });