diff --git a/package.json b/package.json index 79c3bbb..19978ee 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@keyv/redis": "2.7.0", "cheerio": "1.0.0-rc.12", + "cron": "2.4.3", "discord.js": "14.13.0", "env-var": "7.4.1", "keyv": "4.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01476c3..5da5b43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: cheerio: specifier: 1.0.0-rc.12 version: 1.0.0-rc.12 + cron: + specifier: 2.4.3 + version: 2.4.3 discord.js: specifier: 14.13.0 version: 14.13.0 @@ -712,6 +715,10 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/luxon@3.3.2: + resolution: {integrity: sha512-l5cpE57br4BIjK+9BSkFBOsWtwv6J9bJpC7gdXIzZyI0vuKvNTk0wZZrkQxMGsUAuGW9+WMNWF2IJMD7br2yeQ==} + dev: false + /@types/node@20.5.8: resolution: {integrity: sha512-eajsR9aeljqNhK028VG0Wuw+OaY5LLxYmxeoXynIoE6jannr9/Ucd1LL0hSSoafk5LTYG+FfqsyGt81Q6Zkybw==} @@ -1177,6 +1184,13 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /cron@2.4.3: + resolution: {integrity: sha512-YBvExkQYF7w0PxyeFLRyr817YVDhGxaCi5/uRRMqa4aWD3IFKRd+uNbpW1VWMdqQy8PZ7CElc+accXJcauPKzQ==} + dependencies: + '@types/luxon': 3.3.2 + luxon: 3.3.0 + dev: false + /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -2247,6 +2261,11 @@ packages: yallist: 4.0.0 dev: true + /luxon@3.3.0: + resolution: {integrity: sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==} + engines: {node: '>=12'} + dev: false + /magic-bytes.js@1.0.15: resolution: {integrity: sha512-bpRmwbRHqongRhA+mXzbLWjVy7ylqmfMBYaQkSs6pac0z6hBTvsgrH0r4FBYd/UYVJBmS6Rp/O+oCCQVLzKV1g==} dev: false diff --git a/src/core/cache.ts b/src/core/cache.ts index 329a2fc..896e49e 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -3,6 +3,7 @@ import '@keyv/redis'; import Keyv from 'keyv'; import { config } from '../config'; +import type { Frequency } from '../modules/recurringMessage/recurringMessage.helpers'; // eslint-disable-next-line @typescript-eslint/no-explicit-any interface CacheGet> { @@ -22,6 +23,7 @@ interface Cache> { interface CacheEntries { lobbyId: string; channels: string[]; + recurringMessages: { id: string; channelId: string; frequency: Frequency; message: string }[]; } class CacheImpl implements Cache { diff --git a/src/helpers/roles.ts b/src/helpers/roles.ts new file mode 100644 index 0000000..7002449 --- /dev/null +++ b/src/helpers/roles.ts @@ -0,0 +1,8 @@ +import { type APIInteractionGuildMember, GuildMember } from 'discord.js'; + +export const isAdmin = (member: GuildMember | APIInteractionGuildMember | null): boolean => + member instanceof GuildMember && member.roles.cache.some((role) => role.name === 'Admin'); + +export const isModo = (member: GuildMember | APIInteractionGuildMember | null): boolean => + member instanceof GuildMember && + member.roles.cache.some((role) => role.name === 'Admin' || role.name === 'Modo'); diff --git a/src/modules/modules.ts b/src/modules/modules.ts index 4e6bdc8..307014c 100644 --- a/src/modules/modules.ts +++ b/src/modules/modules.ts @@ -1,6 +1,7 @@ import { coolLinksManagement } from './coolLinksManagement/coolLinksManagement.module'; import { fart } from './fart/fart.module'; import { patternReplace } from './patternReplace/patternReplace.module'; +import { recurringMessage } from './recurringMessage/recurringMessage.module'; import { voiceOnDemand } from './voiceOnDemand/voiceOnDemand.module'; export const modules = { @@ -8,4 +9,5 @@ export const modules = { voiceOnDemand, coolLinksManagement, patternReplace, + recurringMessage, }; diff --git a/src/modules/recurringMessage/recurringMessage.helpers.ts b/src/modules/recurringMessage/recurringMessage.helpers.ts new file mode 100644 index 0000000..6e55c38 --- /dev/null +++ b/src/modules/recurringMessage/recurringMessage.helpers.ts @@ -0,0 +1,138 @@ +import { CronJob } from 'cron'; +import { randomUUID } from 'crypto'; +import type { ChatInputCommandInteraction, Client } from 'discord.js'; + +import { cache } from '../../core/cache'; +import { isModo } from '../../helpers/roles'; + +const MAX_MESSAGE_LENGTH = 2000; + +const cronTime = { + daily: '0 0 9 * * *', + weekly: '0 0 9 * * 1', + monthly: '0 0 9 1 * *', +}; + +const frequencyDisplay = { + daily: 'every day at 9am', + weekly: 'every monday at 9am', + monthly: 'the 1st of every month at 9am', +}; + +const inMemoryJobList: { id: string; job: CronJob }[] = []; + +export type Frequency = keyof typeof cronTime; + +export const hasPermission = (interaction: ChatInputCommandInteraction) => { + if (!isModo(interaction.member)) { + interaction.reply('You are not allowed to use this command').catch(console.error); + return false; + } + return true; +}; + +export const createRecurringMessage = ( + client: Client, + channelId: string, + frequency: Frequency, + message: string, +): CronJob => { + return new CronJob( + cronTime[frequency], + () => { + const channel = client.channels.cache.get(channelId); + if (!channel || !channel.isTextBased()) { + console.error(`Channel ${channelId} not found`); + return; + } + channel.send(message).catch(console.error); + }, + null, + true, + 'Europe/Paris', + ); +}; + +export const addRecurringMessage = async (interaction: ChatInputCommandInteraction) => { + const jobId = randomUUID(); + const channelId = interaction.channelId; + const frequency = interaction.options.getString('frequency', true) as Frequency; + const message = interaction.options.getString('message', true); + + const displayIdInMessage = `\n (id: ${jobId})`; + const jobMessage = message + displayIdInMessage; + + if (jobMessage.length > MAX_MESSAGE_LENGTH) { + interaction + .reply( + `Message is too long (max ${MAX_MESSAGE_LENGTH - displayIdInMessage.length} characters)`, + ) + .catch(console.error); + return; + } + + const job = createRecurringMessage(interaction.client, channelId, frequency, jobMessage); + job.start(); + + inMemoryJobList.push({ id: jobId, job }); + + const recurringMessages = await cache.get('recurringMessages', []); + await cache.set('recurringMessages', [ + ...recurringMessages, + { id: jobId, channelId, frequency, message }, + ]); + + await interaction.reply(`Recurring message added ${frequencyDisplay[frequency]}`); +}; + +export const removeRecurringMessage = async (interaction: ChatInputCommandInteraction) => { + const jobId = interaction.options.getString('id', true); + + console.log(jobId, inMemoryJobList); + + const recurringMessages = await cache.get('recurringMessages', []); + await cache.set( + 'recurringMessages', + recurringMessages.filter(({ id }) => id !== jobId), + ); + + const job = inMemoryJobList.find(({ id }) => id === jobId)?.job; + if (!job) { + interaction.reply('Recurring message not found').catch(console.error); + return; + } + + job.stop(); + + await interaction.reply('Recurring message removed'); +}; + +export const listRecurringMessages = async (interaction: ChatInputCommandInteraction) => { + const recurringMessages = await cache.get('recurringMessages', []); + + if (recurringMessages.length === 0) { + interaction.reply('No recurring message found').catch(console.error); + return; + } + + const recurringMessagesList = recurringMessages + .map( + ({ id, frequency, message }) => + `id: ${id} - frequency: ${frequency} - ${message.substring(0, 50)}${ + message.length > 50 ? '...' : '' + }`, + ) + .join('\n'); + + await interaction.reply(recurringMessagesList); +}; + +export const relaunchRecurringMessages = async (client: Client) => { + const recurringMessages = await cache.get('recurringMessages', []); + + recurringMessages.forEach(({ id, channelId, frequency, message }) => { + const job = createRecurringMessage(client, channelId, frequency, message); + job.start(); + inMemoryJobList.push({ id, job }); + }); +}; diff --git a/src/modules/recurringMessage/recurringMessage.module.ts b/src/modules/recurringMessage/recurringMessage.module.ts new file mode 100644 index 0000000..c08be2b --- /dev/null +++ b/src/modules/recurringMessage/recurringMessage.module.ts @@ -0,0 +1,77 @@ +import { SlashCommandBuilder } from 'discord.js'; + +import type { BotModule } from '../../types/bot'; +import { + addRecurringMessage, + hasPermission, + listRecurringMessages, + relaunchRecurringMessages, + removeRecurringMessage, +} from './recurringMessage.helpers'; + +export const recurringMessage: BotModule = { + slashCommands: [ + { + schema: new SlashCommandBuilder() + .setName('recurrent') + .setDescription('Manage recurring messages') + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('Add a recurring message') + .addStringOption((option) => + option + .setName('frequency') + .setDescription('How often to send the message') + .addChoices( + { name: 'daily', value: 'daily' }, + { name: 'weekly', value: 'weekly' }, + { name: 'monthly', value: 'monthly' }, + ) + .setRequired(true), + ) + .addStringOption((option) => + option.setName('message').setDescription('The message to send').setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove a recurring message') + .addStringOption((option) => + option + .setName('id') + .setDescription('The id of the recurring message to remove') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand.setName('list').setDescription('List recurring messages'), + ) + .toJSON(), + handler: { + add: async (interaction) => { + if (!hasPermission(interaction)) return; + + await addRecurringMessage(interaction); + }, + remove: async (interaction) => { + if (!hasPermission(interaction)) return; + + await removeRecurringMessage(interaction); + }, + list: async (interaction) => { + if (!hasPermission(interaction)) return; + + await listRecurringMessages(interaction); + }, + }, + }, + ], + eventHandlers: { + ready: async (client) => { + // relaunch recurring messages on bot restart + await relaunchRecurringMessages(client); + }, + }, +};