Skip to content

Commit acabf83

Browse files
luca-montaigutpotb
authored andcommitted
feat: manage recurring messages (#53)
1 parent f2c338d commit acabf83

File tree

7 files changed

+251
-0
lines changed

7 files changed

+251
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"dependencies": {
1717
"@keyv/redis": "2.7.0",
1818
"cheerio": "1.0.0-rc.12",
19+
"cron": "2.4.3",
1920
"discord.js": "14.13.0",
2021
"env-var": "7.4.1",
2122
"keyv": "4.5.3",

pnpm-lock.yaml

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/cache.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import '@keyv/redis';
33
import Keyv from 'keyv';
44

55
import { config } from '../config';
6+
import type { Frequency } from '../modules/recurringMessage/recurringMessage.helpers';
67

78
// eslint-disable-next-line @typescript-eslint/no-explicit-any
89
interface CacheGet<Entries extends Record<string, any>> {
@@ -23,6 +24,7 @@ interface CacheEntries {
2324
lobbyId: string;
2425
channels: string[];
2526
quoiFeurChannels: string[];
27+
recurringMessages: { id: string; channelId: string; frequency: Frequency; message: string }[];
2628
}
2729

2830
class CacheImpl implements Cache<CacheEntries> {

src/helpers/roles.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { type APIInteractionGuildMember, GuildMember } from 'discord.js';
2+
3+
export const isAdmin = (member: GuildMember | APIInteractionGuildMember | null): boolean =>
4+
member instanceof GuildMember && member.roles.cache.some((role) => role.name === 'Admin');
5+
6+
export const isModo = (member: GuildMember | APIInteractionGuildMember | null): boolean =>
7+
member instanceof GuildMember &&
8+
member.roles.cache.some((role) => role.name === 'Admin' || role.name === 'Modo');

src/modules/modules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { coolLinksManagement } from './coolLinksManagement/coolLinksManagement.m
22
import { fart } from './fart/fart.module';
33
import { patternReplace } from './patternReplace/patternReplace.module';
44
import { quoiFeur } from './quoiFeur/quoiFeur.module';
5+
import { recurringMessage } from './recurringMessage/recurringMessage.module';
56
import { voiceOnDemand } from './voiceOnDemand/voiceOnDemand.module';
67

78
export const modules = {
@@ -10,4 +11,5 @@ export const modules = {
1011
coolLinksManagement,
1112
patternReplace,
1213
quoiFeur,
14+
recurringMessage,
1315
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { CronJob } from 'cron';
2+
import { randomUUID } from 'crypto';
3+
import type { ChatInputCommandInteraction, Client } from 'discord.js';
4+
5+
import { cache } from '../../core/cache';
6+
import { isModo } from '../../helpers/roles';
7+
8+
const MAX_MESSAGE_LENGTH = 2000;
9+
10+
const cronTime = {
11+
daily: '0 0 9 * * *',
12+
weekly: '0 0 9 * * 1',
13+
monthly: '0 0 9 1 * *',
14+
};
15+
16+
const frequencyDisplay = {
17+
daily: 'every day at 9am',
18+
weekly: 'every monday at 9am',
19+
monthly: 'the 1st of every month at 9am',
20+
};
21+
22+
const inMemoryJobList: { id: string; job: CronJob }[] = [];
23+
24+
export type Frequency = keyof typeof cronTime;
25+
26+
export const isFrequency = (frequency: string): frequency is Frequency => {
27+
return Object.keys(cronTime).includes(frequency);
28+
};
29+
30+
export const hasPermission = (interaction: ChatInputCommandInteraction) => {
31+
if (!isModo(interaction.member)) {
32+
interaction.reply('You are not allowed to use this command').catch(console.error);
33+
return false;
34+
}
35+
return true;
36+
};
37+
38+
export const createRecurringMessage = (
39+
client: Client<true>,
40+
channelId: string,
41+
frequency: Frequency,
42+
message: string,
43+
): CronJob => {
44+
return new CronJob(
45+
cronTime[frequency],
46+
() => {
47+
const channel = client.channels.cache.get(channelId);
48+
if (!channel || !channel.isTextBased()) {
49+
console.error(`Channel ${channelId} not found`);
50+
return;
51+
}
52+
void channel.send(message);
53+
},
54+
null,
55+
true,
56+
'Europe/Paris',
57+
);
58+
};
59+
60+
export const addRecurringMessage = async (interaction: ChatInputCommandInteraction) => {
61+
const jobId = randomUUID();
62+
const channelId = interaction.channelId;
63+
const frequency = interaction.options.getString('frequency', true);
64+
if (!isFrequency(frequency)) {
65+
await interaction.reply(`${frequency} is not a valid frequency`);
66+
return;
67+
}
68+
const message = interaction.options.getString('message', true);
69+
70+
const displayIdInMessage = `\n (id: ${jobId})`;
71+
const jobMessage = message + displayIdInMessage;
72+
73+
if (jobMessage.length > MAX_MESSAGE_LENGTH) {
74+
await interaction.reply(
75+
`Message is too long (max ${MAX_MESSAGE_LENGTH - displayIdInMessage.length} characters)`,
76+
);
77+
return;
78+
}
79+
80+
const job = createRecurringMessage(interaction.client, channelId, frequency, jobMessage);
81+
job.start();
82+
83+
inMemoryJobList.push({ id: jobId, job });
84+
85+
const recurringMessages = await cache.get('recurringMessages', []);
86+
await cache.set('recurringMessages', [
87+
...recurringMessages,
88+
{ id: jobId, channelId, frequency, message },
89+
]);
90+
91+
await interaction.reply(`Recurring message added ${frequencyDisplay[frequency]}`);
92+
};
93+
94+
export const removeRecurringMessage = async (interaction: ChatInputCommandInteraction) => {
95+
const jobId = interaction.options.getString('id', true);
96+
97+
console.log(jobId, inMemoryJobList);
98+
99+
const recurringMessages = await cache.get('recurringMessages', []);
100+
await cache.set(
101+
'recurringMessages',
102+
recurringMessages.filter(({ id }) => id !== jobId),
103+
);
104+
105+
const job = inMemoryJobList.find(({ id }) => id === jobId)?.job;
106+
if (!job) {
107+
await interaction.reply('Recurring message not found');
108+
return;
109+
}
110+
111+
job.stop();
112+
113+
await interaction.reply('Recurring message removed');
114+
};
115+
116+
export const listRecurringMessages = async (interaction: ChatInputCommandInteraction) => {
117+
const recurringMessages = await cache.get('recurringMessages', []);
118+
119+
if (recurringMessages.length === 0) {
120+
await interaction.reply('No recurring message found');
121+
return;
122+
}
123+
124+
const recurringMessagesList = recurringMessages
125+
.map(
126+
({ id, frequency, message }) =>
127+
`id: ${id} - frequency: ${frequency} - ${message.substring(0, 50)}${
128+
message.length > 50 ? '...' : ''
129+
}`,
130+
)
131+
.join('\n');
132+
133+
await interaction.reply(recurringMessagesList);
134+
};
135+
136+
export const relaunchRecurringMessages = async (client: Client<true>) => {
137+
const recurringMessages = await cache.get('recurringMessages', []);
138+
139+
recurringMessages.forEach(({ id, channelId, frequency, message }) => {
140+
const job = createRecurringMessage(client, channelId, frequency, message);
141+
job.start();
142+
inMemoryJobList.push({ id, job });
143+
});
144+
};
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { SlashCommandBuilder } from 'discord.js';
2+
3+
import type { BotModule } from '../../types/bot';
4+
import {
5+
addRecurringMessage,
6+
hasPermission,
7+
listRecurringMessages,
8+
relaunchRecurringMessages,
9+
removeRecurringMessage,
10+
} from './recurringMessage.helpers';
11+
12+
export const recurringMessage: BotModule = {
13+
slashCommands: [
14+
{
15+
schema: new SlashCommandBuilder()
16+
.setName('recurrent')
17+
.setDescription('Manage recurring messages')
18+
.addSubcommand((subcommand) =>
19+
subcommand
20+
.setName('add')
21+
.setDescription('Add a recurring message')
22+
.addStringOption((option) =>
23+
option
24+
.setName('frequency')
25+
.setDescription('How often to send the message')
26+
.addChoices(
27+
{ name: 'daily', value: 'daily' },
28+
{ name: 'weekly', value: 'weekly' },
29+
{ name: 'monthly', value: 'monthly' },
30+
)
31+
.setRequired(true),
32+
)
33+
.addStringOption((option) =>
34+
option.setName('message').setDescription('The message to send').setRequired(true),
35+
),
36+
)
37+
.addSubcommand((subcommand) =>
38+
subcommand
39+
.setName('remove')
40+
.setDescription('Remove a recurring message')
41+
.addStringOption((option) =>
42+
option
43+
.setName('id')
44+
.setDescription('The id of the recurring message to remove')
45+
.setRequired(true),
46+
),
47+
)
48+
.addSubcommand((subcommand) =>
49+
subcommand.setName('list').setDescription('List recurring messages'),
50+
)
51+
.toJSON(),
52+
handler: {
53+
add: async (interaction) => {
54+
if (!hasPermission(interaction)) return;
55+
56+
await addRecurringMessage(interaction);
57+
},
58+
remove: async (interaction) => {
59+
if (!hasPermission(interaction)) return;
60+
61+
await removeRecurringMessage(interaction);
62+
},
63+
list: async (interaction) => {
64+
if (!hasPermission(interaction)) return;
65+
66+
await listRecurringMessages(interaction);
67+
},
68+
},
69+
},
70+
],
71+
eventHandlers: {
72+
// relaunch recurring messages on bot restart
73+
ready: relaunchRecurringMessages,
74+
},
75+
};

0 commit comments

Comments
 (0)