Skip to content

feat: /recurrent command to add recurring messages #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
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 @@ -22,6 +23,7 @@ interface Cache<Entries extends Record<string, any>> {
interface CacheEntries {
lobbyId: string;
channels: 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
@@ -1,11 +1,13 @@
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 = {
fart,
voiceOnDemand,
coolLinksManagement,
patternReplace,
recurringMessage,
};
138 changes: 138 additions & 0 deletions src/modules/recurringMessage/recurringMessage.helpers.ts
Original file line number Diff line number Diff line change
@@ -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<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;
}
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<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 });
});
};
77 changes: 77 additions & 0 deletions src/modules/recurringMessage/recurringMessage.module.ts
Original file line number Diff line number Diff line change
@@ -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);
},
},
};