diff --git a/src/crons/CommitStrip.ts b/src/crons/CommitStrip.ts index 31b3281..d17f1f5 100644 --- a/src/crons/CommitStrip.ts +++ b/src/crons/CommitStrip.ts @@ -1,9 +1,13 @@ -import { EmbedBuilder, SnowflakeUtil } from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; import got from 'got'; import { decode } from 'html-entities'; import { KeyValue } from '../database/index.js'; -import { Cron, findTextChannelByName } from '../framework/index.js'; +import { + Cron, + findTextChannelByName, + sendToChannel, +} from '../framework/index.js'; export default new Cron({ enabled: false, @@ -27,15 +31,13 @@ export default new Cron({ const channel = findTextChannelByName(context.client.channels, 'gif'); - await channel.send({ + await sendToChannel(channel, { embeds: [ new EmbedBuilder() .setTitle(strip.title) .setURL(strip.link) .setImage(strip.imageUrl), ], - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), }); }, }); diff --git a/src/crons/DavidRevoy.ts b/src/crons/DavidRevoy.ts index aaa8fc9..3cc68cd 100644 --- a/src/crons/DavidRevoy.ts +++ b/src/crons/DavidRevoy.ts @@ -1,9 +1,8 @@ -import { Cron, findTextChannelByName } from '../framework/index.ts'; +import { Cron, buildBasicCronHandle } from '../framework/index.ts'; import got from 'got'; import { parse } from 'node-html-parser'; import { decode } from 'html-entities'; -import { KeyValue } from '../database/index.ts'; -import { EmbedBuilder, SnowflakeUtil } from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; export default new Cron({ enabled: true, @@ -11,34 +10,21 @@ export default new Cron({ description: 'Vérifie toutes les 30 minutes si un nouveau strip de David Revoy est sorti', schedule: '5,35 * * * *', - async handle(context) { - const strip = await getLastDavidRevoyStrip(); - - // vérifie le strip trouvé avec la dernière entrée - const lastStrip = await KeyValue.get('Last-Cron-DavidRevoy'); - const stripStoreIdentity = strip?.id ?? null; - if (lastStrip === stripStoreIdentity) return; // skip si identique - - await KeyValue.set('Last-Cron-DavidRevoy', stripStoreIdentity); // met à jour sinon - - if (!strip) return; // skip si pas de strip - - context.logger.info(`Found a new David Revoy strip`, strip); - - const channel = findTextChannelByName(context.client.channels, 'gif'); - - await channel.send({ + handle: buildBasicCronHandle({ + key: 'Last-Cron-DavidRevoy', + targetChannelName: 'gif', + logMsg: 'Found a new David Revoy strip', + getLastEntry: getLastDavidRevoyStrip, + toMessage: (entry) => ({ embeds: [ new EmbedBuilder() - .setURL(strip.link) - .setTitle(strip.title) - .setImage(strip.imageUrl) - .setTimestamp(strip.date), + .setURL(entry.link) + .setTitle(entry.title) + .setImage(entry.imageUrl) + .setTimestamp(entry.date), ], - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), - }); - }, + }), + }), }); interface IDavidRevoyStrip { diff --git a/src/crons/EpicGames.ts b/src/crons/EpicGames.ts index bf33794..2e2f0d6 100644 --- a/src/crons/EpicGames.ts +++ b/src/crons/EpicGames.ts @@ -1,9 +1,13 @@ -import { EmbedBuilder, SnowflakeUtil } from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; import got from 'got'; import type { Logger } from 'pino'; import { KeyValue } from '../database/index.js'; -import { Cron, findTextChannelByName } from '../framework/index.js'; +import { + Cron, + findTextChannelByName, + sendToChannel, +} from '../framework/index.js'; const dateFmtOptions: Intl.DateTimeFormatOptions = { timeZone: 'Europe/Paris', @@ -40,7 +44,7 @@ export default new Cron({ if (game.thumbnail) message.setThumbnail(game.thumbnail); if (game.banner) message.setImage(game.banner); - await channel.send({ + await sendToChannel(channel, { embeds: [ message .setDescription(game.description) @@ -69,8 +73,6 @@ export default new Cron({ url: 'https://store.epicgames.com/fr/mobile', }), ], - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), }); } }, diff --git a/src/crons/GoG.ts b/src/crons/GoG.ts index 8f9c877..815f84f 100644 --- a/src/crons/GoG.ts +++ b/src/crons/GoG.ts @@ -1,11 +1,15 @@ -import { EmbedBuilder, SnowflakeUtil } from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; import got from 'got'; import { decode } from 'html-entities'; import { parse } from 'node-html-parser'; import type { Logger } from 'pino'; import { KeyValue } from '../database/index.js'; -import { Cron, findTextChannelByName } from '../framework/index.js'; +import { + Cron, + findTextChannelByName, + sendToChannel, +} from '../framework/index.js'; const dateFmtOptions: Intl.DateTimeFormatOptions = { timeZone: 'Europe/Paris', @@ -78,11 +82,7 @@ export default new Cron({ embed.addFields({ name: 'Note', value: `⭐ ${game.rating}` }); } - await channel.send({ - embeds: [embed], - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), - }); + await sendToChannel(channel, { embeds: [embed] }); }, }); diff --git a/src/crons/NodeJsReleases.ts b/src/crons/NodeJsReleases.ts index cbe429e..d1d4f9e 100644 --- a/src/crons/NodeJsReleases.ts +++ b/src/crons/NodeJsReleases.ts @@ -1,8 +1,11 @@ import got from 'got'; import { KeyValue } from '../database/index.js'; -import { Cron, findTextChannelByName } from '../framework/index.js'; -import { SnowflakeUtil } from 'discord.js'; +import { + Cron, + findTextChannelByName, + sendToChannel, +} from '../framework/index.js'; export default new Cron({ enabled: true, @@ -25,10 +28,8 @@ export default new Cron({ const channel = findTextChannelByName(context.client.channels, 'news'); for (const release of entries) { - await channel.send({ + await sendToChannel(channel, { content: `# Release ${release.title}\n\n<${release.link}>`, - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), }); const content = release.content.replaceAll('\r\n', '\n'); const lines = content.split('\n'); @@ -36,11 +37,7 @@ export default new Cron({ let message = ''; for (const line of lines) { if (message.length + line.length > 2000) { - const m = await channel.send({ - content: message, - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), - }); + const m = await sendToChannel(channel, { content: message }); await m.suppressEmbeds(true); message = ''; @@ -54,19 +51,11 @@ export default new Cron({ message += `\n${line}`; } if (message.trim()) { - const m = await channel.send({ - content: message, - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), - }); + const m = await sendToChannel(channel, { content: message }); await m.suppressEmbeds(true); } - await channel.send({ - content: release.link, - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), - }); + await sendToChannel(channel, { content: release.link }); await KeyValue.set('Last-Cron-Node.js', release.id); // update id in db } diff --git a/src/crons/TurnOff.ts b/src/crons/TurnOff.ts index df0e289..d1d12dd 100644 --- a/src/crons/TurnOff.ts +++ b/src/crons/TurnOff.ts @@ -1,8 +1,12 @@ -import { Cron, findTextChannelByName } from '../framework/index.ts'; +import { + Cron, + findTextChannelByName, + sendToChannel, +} from '../framework/index.ts'; import got from 'got'; import { parse } from 'node-html-parser'; import { KeyValue } from '../database/index.ts'; -import { EmbedBuilder, SnowflakeUtil } from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; export default new Cron({ enabled: true, @@ -26,7 +30,7 @@ export default new Cron({ const channel = findTextChannelByName(context.client.channels, 'gif'); - await channel.send({ + await sendToChannel(channel, { embeds: [ new EmbedBuilder() .setURL(strip.link) @@ -34,8 +38,6 @@ export default new Cron({ .setImage(strip.imageUrl) .setTimestamp(strip.date), ], - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), }); }, }); diff --git a/src/crons/WorkChronicles.ts b/src/crons/WorkChronicles.ts index 92e167e..f1fa52f 100644 --- a/src/crons/WorkChronicles.ts +++ b/src/crons/WorkChronicles.ts @@ -1,8 +1,12 @@ -import { EmbedBuilder, SnowflakeUtil } from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; import got from 'got'; import { KeyValue } from '../database/index.js'; -import { Cron, findTextChannelByName } from '../framework/index.js'; +import { + Cron, + findTextChannelByName, + sendToChannel, +} from '../framework/index.js'; export default new Cron({ enabled: true, @@ -26,15 +30,13 @@ export default new Cron({ const channel = findTextChannelByName(context.client.channels, 'gif'); - await channel.send({ + await sendToChannel(channel, { embeds: [ new EmbedBuilder() .setTitle(chronicle.title) .setURL(chronicle.link) .setImage(chronicle.imageUrl), ], - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), }); }, }); diff --git a/src/crons/XKCD.ts b/src/crons/XKCD.ts index 82ad10e..7ead633 100644 --- a/src/crons/XKCD.ts +++ b/src/crons/XKCD.ts @@ -1,10 +1,14 @@ -import { EmbedBuilder, SnowflakeUtil } from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; import got from 'got'; import { decode } from 'html-entities'; import { parse } from 'node-html-parser'; import { KeyValue } from '../database/index.js'; -import { Cron, findTextChannelByName } from '../framework/index.js'; +import { + Cron, + findTextChannelByName, + sendToChannel, +} from '../framework/index.js'; export default new Cron({ enabled: true, @@ -28,7 +32,7 @@ export default new Cron({ const channel = findTextChannelByName(context.client.channels, 'gif'); - await channel.send({ + await sendToChannel(channel, { embeds: [ new EmbedBuilder() .setURL(strip.link) @@ -37,8 +41,6 @@ export default new Cron({ .setTimestamp(strip.date) .setFooter({ text: strip.description }), ], - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), }); }, }); diff --git a/src/database/KeyValue.ts b/src/database/KeyValue.ts index d13b19e..8958400 100644 --- a/src/database/KeyValue.ts +++ b/src/database/KeyValue.ts @@ -1,7 +1,7 @@ import DB from './database.js'; type JSONScalar = boolean | number | string | null; -type JSONTypes = JSONScalar | JSONObject | JSONArray; +export type JSONTypes = JSONScalar | JSONObject | JSONArray; interface JSONObject { [member: string]: JSONTypes; } diff --git a/src/framework/Cron.ts b/src/framework/Cron.ts index d5eba9e..757e639 100644 --- a/src/framework/Cron.ts +++ b/src/framework/Cron.ts @@ -1,14 +1,14 @@ import { randomUUID } from 'node:crypto'; import { CronJob, CronTime } from 'cron'; -import { type Client, SnowflakeUtil } from 'discord.js'; +import type { Client } from 'discord.js'; import { EmbedBuilder } from 'discord.js'; import type { Logger } from 'pino'; import type { BaseConfig } from './Base.js'; import { Base } from './Base.js'; import type { Bot } from './Bot.js'; -import { findTextChannelByName } from './helpers.js'; +import { findTextChannelByName, sendToChannel } from './helpers.js'; export type CronHandler = (context: CronContext) => Promise; @@ -69,7 +69,8 @@ export class Cron extends Base { } catch (error) { logger.error(error, 'cron handler error'); try { - await findTextChannelByName(bot.client.channels, 'logs').send({ + const logsChannel = findTextChannelByName(bot.client.channels, 'logs'); + await sendToChannel(logsChannel, { embeds: [ new EmbedBuilder() .setTitle('Cron run failed') @@ -80,8 +81,6 @@ export class Cron extends Base { .setDescription(`\`\`\`\n${error.stack}\n\`\`\``) .setColor('Red'), ], - enforceNonce: true, - nonce: SnowflakeUtil.generate().toString(), }); } catch (error_) { logger.error(error_, 'failed to send error to #logs'); diff --git a/src/framework/FormatChecker.ts b/src/framework/FormatChecker.ts index 7d24ec2..e42abd3 100644 --- a/src/framework/FormatChecker.ts +++ b/src/framework/FormatChecker.ts @@ -6,7 +6,11 @@ import type { Logger } from 'pino'; import type { BaseConfig } from './Base.js'; import { Base } from './Base.js'; import type { Bot } from './Bot.js'; -import { findTextChannelByName, isTextChannel } from './helpers.js'; +import { + findTextChannelByName, + isTextChannel, + sendToChannel, +} from './helpers.js'; type FunctionChecker = (cleanContent: string, logger: Logger) => boolean; @@ -101,7 +105,9 @@ export class FormatChecker extends Base { } const channel = findTextChannelByName(message.guild!.channels, 'logs'); - channel.send(`Bonjour ${author},\n${warningContent}`); + void sendToChannel(channel, { + content: `Bonjour ${author},\n${warningContent}`, + }); } logger.debug('warning message sent'); } diff --git a/src/framework/helpers.ts b/src/framework/helpers.ts index 4ed87bd..61d498b 100644 --- a/src/framework/helpers.ts +++ b/src/framework/helpers.ts @@ -1,5 +1,13 @@ -import type { Channel, ChannelManager, GuildChannelManager } from 'discord.js'; +import { + type Channel, + type ChannelManager, + type GuildChannelManager, + type MessageCreateOptions, + SnowflakeUtil, +} from 'discord.js'; import { TextChannel } from 'discord.js'; +import type { CronContext } from './Cron.ts'; +import { type JSONTypes, KeyValue } from '../database/index.ts'; export function findTextChannelByName( manager: ChannelManager | GuildChannelManager, @@ -20,3 +28,62 @@ export function findTextChannelByName( export function isTextChannel(channel: Channel): channel is TextChannel { return channel instanceof TextChannel; } + +/** + * Envoie un message dans un canal en mettant un nonce. + * Évite des race-conditions qui ré-envoient plusieurs fois le même message. + * + * @param channel + * @param message + */ +export function sendToChannel( + channel: TextChannel, + message: Omit, +) { + return channel.send({ + ...message, + enforceNonce: true, + nonce: SnowflakeUtil.generate().toString(), + }); +} + +export interface BuildBasicCronHandleOptions< + Id extends JSONTypes, + Entry extends { id: Id }, +> { + getLastEntry: () => Promise; + key: string; + logMsg: string; + targetChannelName: string; + toMessage: ( + entry: Entry, + ) => Omit; +} +export function buildBasicCronHandle< + Id extends JSONTypes, + Entry extends { id: Id }, +>(options: BuildBasicCronHandleOptions) { + const { key, getLastEntry, logMsg, targetChannelName, toMessage } = options; + + return async (context: CronContext) => { + const entry = await getLastEntry(); + + const lastStoredEntry = await KeyValue.get(key); + const entryStoreIdentity = entry?.id ?? null; + if (lastStoredEntry === entryStoreIdentity) return; + + await KeyValue.set(key, entryStoreIdentity); + + if (!entry) return; + + context.logger.info(logMsg, entry); + + const channel = findTextChannelByName( + context.client.channels, + targetChannelName, + ); + + const message = toMessage(entry); + await sendToChannel(channel, message); + }; +}