Skip to content
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entries extends Record<string, any>> {
Expand All @@ -23,6 +24,7 @@ interface CacheEntries {
lobbyId: string;
channels: string[];
quoiFeurChannels: string[];
recurringMessages: { id: string; channelId: string; frequency: Frequency; message: string }[];
}

class CacheImpl implements Cache<CacheEntries> {
Expand Down
8 changes: 8 additions & 0 deletions src/helpers/roles.ts
Original file line number Diff line number Diff line change
@@ -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');
2 changes: 2 additions & 0 deletions src/modules/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { coolLinksManagement } from './coolLinksManagement/coolLinksManagement.m
import { fart } from './fart/fart.module';
import { patternReplace } from './patternReplace/patternReplace.module';
import { quoiFeur } from './quoiFeur/quoiFeur.module';
import { recurringMessage } from './recurringMessage/recurringMessage.module';
import { voiceOnDemand } from './voiceOnDemand/voiceOnDemand.module';

export const modules = {
Expand All @@ -10,4 +11,5 @@ export const modules = {
coolLinksManagement,
patternReplace,
quoiFeur,
recurringMessage,
};
144 changes: 144 additions & 0 deletions src/modules/recurringMessage/recurringMessage.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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 isFrequency = (frequency: string): frequency is Frequency => {
return Object.keys(cronTime).includes(frequency);
};

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<true>,
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;
}
void channel.send(message);
},
null,
true,
'Europe/Paris',
);
};

export const addRecurringMessage = async (interaction: ChatInputCommandInteraction) => {
const jobId = randomUUID();
const channelId = interaction.channelId;
const frequency = interaction.options.getString('frequency', true);
if (!isFrequency(frequency)) {
await interaction.reply(`${frequency} is not a valid frequency`);
return;
}
const message = interaction.options.getString('message', true);

const displayIdInMessage = `\n (id: ${jobId})`;
const jobMessage = message + displayIdInMessage;

if (jobMessage.length > MAX_MESSAGE_LENGTH) {
await interaction.reply(
`Message is too long (max ${MAX_MESSAGE_LENGTH - displayIdInMessage.length} characters)`,
);
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) {
await interaction.reply('Recurring message not found');
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) {
await interaction.reply('No recurring message found');
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<true>) => {
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 });
});
};
75 changes: 75 additions & 0 deletions src/modules/recurringMessage/recurringMessage.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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: {
// relaunch recurring messages on bot restart
ready: relaunchRecurringMessages,
},
};