From b3d050599f2140623805a230e1c69b361f4e841e Mon Sep 17 00:00:00 2001 From: RedGuy12 Date: Mon, 7 Aug 2023 08:42:46 -0500 Subject: [PATCH] the long awaited reminders rewrite (w/ timeout) Signed-off-by: RedGuy12 --- modules/punishments/ban.ts | 8 +- modules/reminders.ts | 540 -------------------------------- modules/reminders/index.ts | 100 ++++++ modules/reminders/management.ts | 201 ++++++++++++ modules/reminders/misc.ts | 102 ++++++ modules/reminders/send.ts | 197 ++++++++++++ modules/threads.ts | 6 +- modules/xp/weekly.ts | 2 +- 8 files changed, 613 insertions(+), 543 deletions(-) delete mode 100644 modules/reminders.ts create mode 100644 modules/reminders/index.ts create mode 100644 modules/reminders/management.ts create mode 100644 modules/reminders/misc.ts create mode 100644 modules/reminders/send.ts diff --git a/modules/punishments/ban.ts b/modules/punishments/ban.ts index bd1220236..2e79b3202 100644 --- a/modules/punishments/ban.ts +++ b/modules/punishments/ban.ts @@ -9,10 +9,11 @@ import { import constants from "../../common/constants.js"; import { disableComponents } from "../../util/discord.js"; import { parseTime } from "../../util/numbers.js"; -import { SpecialReminders, remindersDatabase } from "../reminders.js"; +import { SpecialReminders, remindersDatabase } from "../reminders/misc.js"; import { client } from "strife.js"; import config from "../../common/config.js"; import { escapeMessage } from "../../util/markdown.js"; +import queueReminders from "../reminders/send.js"; export default async function ban(interaction: ChatInputCommandInteraction<"cached" | "raw">) { const memberToBan = interaction.options.getMember("user"); @@ -56,6 +57,8 @@ export default async function ban(interaction: ChatInputCommandInteraction<"cach reminder: userToBan.id, }, ]; + await queueReminders(); + await interaction.reply( `${ constants.emojis.statuses.yes @@ -73,6 +76,8 @@ export default async function ban(interaction: ChatInputCommandInteraction<"cach reminder.reminder === userToBan.id ), ); + await queueReminders(); + await interaction.reply( `${ constants.emojis.statuses.yes @@ -149,6 +154,7 @@ export default async function ban(interaction: ChatInputCommandInteraction<"cach reminder: userToBan.id, }, ]; + await queueReminders(); await userToBan ?.send({ diff --git a/modules/reminders.ts b/modules/reminders.ts deleted file mode 100644 index 94136f515..000000000 --- a/modules/reminders.ts +++ /dev/null @@ -1,540 +0,0 @@ -import { - ApplicationCommandOptionType, - ButtonStyle, - ChannelType, - ComponentType, - GuildMember, - MessageComponentInteraction, - MessageFlags, - type Snowflake, - time, - TimestampStyles, - ThreadAutoArchiveDuration, -} from "discord.js"; -import config from "../common/config.js"; -import constants from "../common/constants.js"; -import Database, { backupDatabases, cleanDatabaseListeners } from "../common/database.js"; -import censor, { badWordsAllowed } from "./automod/language.js"; -import { disableComponents } from "../util/discord.js"; -import { convertBase, nth, parseTime } from "../util/numbers.js"; -import { getSettings } from "./settings.js"; -import { defineButton, defineSelect, client, defineCommand, defineEvent } from "strife.js"; -import getWeekly, { getChatters } from "./xp/weekly.js"; -import warn from "./punishments/warn.js"; -import { getLevelForXp, xpDatabase } from "./xp/misc.js"; - -export enum SpecialReminders { - Weekly, - UpdateSACategory, - Bump, - RebootBot, - CloseThread, - LockThread, - Unban, - BackupDatabases, -} -type Reminder = { - channel: Snowflake; - date: number; - reminder?: string | number; - user: Snowflake; - id: string | SpecialReminders; -}; - -const BUMPING_THREAD = "881619501018394725", - COMMAND_ID = "947088344167366698"; - -export const remindersDatabase = new Database("reminders"); -await remindersDatabase.init(); - -setInterval(async () => { - const { toSend, toPostpone } = remindersDatabase.data.reduce<{ - toSend: Reminder[]; - toPostpone: Reminder[]; - }>( - (acc, reminder) => { - acc[reminder.date - Date.now() < 60_000 ? "toSend" : "toPostpone"].push(reminder); - return acc; - }, - { toSend: [], toPostpone: [] }, - ); - remindersDatabase.data = toPostpone; - - const promises = toSend.map(async (reminder) => { - const channel = await client.channels.fetch(reminder.channel).catch(() => {}); - if (reminder.user === client.user.id) { - switch (reminder.id) { - case SpecialReminders.Weekly: { - if (!channel?.isTextBased()) return; - - const date = new Date(); - date.setUTCDate(date.getUTCDate() - 7); - const nextWeeklyDate = new Date(reminder.date); - nextWeeklyDate.setUTCDate(nextWeeklyDate.getUTCDate() + 7); - - const chatters = await getChatters(); - const message = await channel.send(await getWeekly(nextWeeklyDate)); - if (!chatters) return message; - const thread = await message.startThread({ - name: `🏆 Weekly Winners week of ${ - [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ][date.getUTCMonth()] || "" - } ${nth(date.getUTCDate(), { bold: false, jokes: false })}`, - reason: "To send all chatters", - }); - await thread.send(chatters); - return message; - } - case SpecialReminders.UpdateSACategory: { - if (channel?.type !== ChannelType.GuildCategory) return; - - remindersDatabase.data = [ - ...remindersDatabase.data, - { - channel: reminder.channel, - date: Number(Date.now() + 3_600_000), - reminder: undefined, - id: SpecialReminders.UpdateSACategory, - user: client.user.id, - }, - ]; - - const count = await fetch( - `${constants.urls.usercountJson}?date=${Date.now()}`, - ).then( - async (response) => - await response?.json<{ count: number; _chromeCountDate: string }>(), - ); - - return await channel?.setName( - `Scratch Addons - ${count.count.toLocaleString("en-us", { - compactDisplay: "short", - maximumFractionDigits: 1, - minimumFractionDigits: +(count.count > 999), - notation: "compact", - })} users`, - "Automated update to sync count", - ); - } - case SpecialReminders.Bump: { - if (!channel?.isTextBased()) return; - - remindersDatabase.data = [ - ...remindersDatabase.data, - { - channel: BUMPING_THREAD, - date: Date.now() + 1800000, - reminder: undefined, - id: SpecialReminders.Bump, - user: client.user.id, - }, - ]; - - return await channel.send({ - content: `🔔 @here the server!`, - allowedMentions: { parse: ["everyone"] }, - }); - } - case SpecialReminders.RebootBot: { - await cleanDatabaseListeners(); - process.emitWarning(`${client.user.tag} is killing the bot`); - // eslint-disable-next-line unicorn/no-process-exit -- This is how you restart the process on Railway. - process.exit(1); - } - case SpecialReminders.CloseThread: { - if (channel?.isThread()) await channel.setArchived(true, "Close requested"); - return; - } - case SpecialReminders.LockThread: { - if (channel?.isThread()) await channel.setLocked(true, "Lock requested"); - return; - } - case SpecialReminders.Unban: { - await config.guild.bans.remove( - String(reminder.reminder), - "Unbanned after set time period", - ); - return; - } - case SpecialReminders.BackupDatabases: { - if (!channel?.isTextBased()) return; - - remindersDatabase.data = [ - ...remindersDatabase.data, - { - channel: reminder.channel, - date: Number(Date.now() + 86_400), - reminder: undefined, - id: SpecialReminders.BackupDatabases, - user: client.user.id, - }, - ]; - - return backupDatabases(channel); - } - } - } - if (!channel?.isTextBased() || typeof reminder.reminder !== "string") return; - const silent = reminder.reminder.startsWith("@silent"); - const content = silent ? reminder.reminder.replace("@silent", "") : reminder.reminder; - await channel - .send({ - content: `🔔 ${ - channel.isDMBased() ? "" : `<@${reminder.user}> ` - }${content.trim()} (from ${time( - new Date(+convertBase(reminder.id + "", convertBase.MAX_BASE, 10)), - TimestampStyles.RelativeTime, - )})`, - allowedMentions: { users: [reminder.user] }, - flags: silent ? MessageFlags.SuppressNotifications : undefined, - }) - .catch(() => {}); - }); - await Promise.all(promises); -}, 120_000); - -defineCommand( - { - name: "reminders", - description: "Commands to manage reminders", - censored: false, - subcommands: { - add: { - description: "Sets a reminder", - options: { - dms: { - type: ApplicationCommandOptionType.Boolean, - description: - "Send the reminder in DMs instead of this channel (defaults to true unless changed with /settings)", - }, - time: { - type: ApplicationCommandOptionType.String, - required: true, - description: - "How long until sending the reminder or a UNIX timestamp to send it at (within one minute)", - }, - reminder: { - type: ApplicationCommandOptionType.String, - required: true, - description: "Reminder to send", - maxLength: 125, - }, - }, - }, - list: { description: "View your reminders" }, - }, - }, - async (interaction) => { - const reminders = remindersDatabase.data - .filter((reminder) => reminder.user === interaction.user.id) - .sort((one, two) => one.date - two.date); - if (interaction.options.getSubcommand(true) === "list") { - if (!reminders.length) - return await interaction.reply({ - ephemeral: true, - content: `${constants.emojis.statuses.no} You don’t have any reminders set!`, - }); - - return await interaction.reply({ - ephemeral: true, - embeds: [ - { - color: - interaction.member instanceof GuildMember - ? interaction.member.displayColor - : undefined, - - author: { - icon_url: (interaction.member instanceof GuildMember - ? interaction.member - : interaction.user - ).displayAvatarURL(), - name: (interaction.member instanceof GuildMember - ? interaction.member - : interaction.user - ).displayName, - }, - footer: { - text: `${reminders.length} reminder${ - reminders.length === 1 ? "" : "s" - }`, - }, - description: reminders - .map( - (reminder) => - `\`${reminder.id}\`) ${time( - new Date(reminder.date), - TimestampStyles.RelativeTime, - )}: <#${reminder.channel}> ${reminder.reminder}`, - ) - .join("\n"), - }, - ], - components: [ - { - type: ComponentType.ActionRow, - components: [ - { - customId: "_cancelReminder", - type: ComponentType.StringSelect, - placeholder: "Cancel", - options: reminders.map((reminder) => ({ - value: reminder.id + "", - description: `${reminder.reminder}`.slice(0, 100), - label: reminder.id + "", - })), - }, - ], - }, - ], - }); - } - const dms = - interaction.options.getBoolean("dms") ?? getSettings(interaction.user).dmReminders; - const reminder = interaction.options.getString("reminder", true); - - if (!dms && !badWordsAllowed(interaction.channel)) { - const censored = censor(reminder); - - if (censored) { - await interaction.reply({ - ephemeral: true, - content: `${constants.emojis.statuses.no} ${ - censored.strikes < 1 ? "That's not appropriate" : "Language" - }!`, - }); - await warn( - interaction.user, - "Watch your language!", - censored.strikes, - `Used command ${interaction.toString()}`, - ); - return; - } - } - - if ( - reminders.length > - Math.ceil( - getLevelForXp( - Math.abs( - xpDatabase.data.find(({ user }) => user === interaction.user.id)?.xp ?? 0, - ), - ) * 0.3, - ) + - 5 - ) { - return await interaction.reply({ - ephemeral: true, - content: `${constants.emojis.statuses.no} You already have ${reminders.length} reminders set! Please cancel some or level up before setting any more.`, - }); - } - - const date = parseTime(interaction.options.getString("time", true).toLowerCase().trim()); - if (+date < Date.now() + 60_000 || +date > Date.now() + 31_536_000_000) { - return await interaction.reply({ - ephemeral: true, - content: `${constants.emojis.statuses.no} Could not parse the time! Make sure to pass in the value as so: \`1h30m\`, for example. Note that I can’t remind you sooner than 1 minute or later than 365 days.`, - }); - } - - const channel = dms - ? (await interaction.user.createDM().catch(() => {}))?.id - : interaction.channel?.id; - if (!channel) - return await interaction.reply({ - ephemeral: true, - content: `${constants.emojis.statuses.no} Your DMs are closed, so I can’t remind you!`, - }); - - const id = convertBase(Date.now() + "", 10, convertBase.MAX_BASE); - remindersDatabase.data = [ - ...remindersDatabase.data, - { channel, date: +date, reminder, user: interaction.user.id, id }, - ]; - - await interaction.reply({ - ephemeral: dms, - content: `${constants.emojis.statuses.yes} I’ll remind you ${time( - date, - TimestampStyles.RelativeTime, - )}!`, - components: [ - { - type: ComponentType.ActionRow, - components: [ - { - type: ComponentType.Button, - label: "Cancel", - customId: `${id}_cancelReminder`, - style: ButtonStyle.Danger, - }, - ], - }, - ], - }); - }, -); - -defineSelect("cancelReminder", async (interaction) => { - const [id = ""] = interaction.values; - const success = await cancelReminder(interaction, id); - if (!success) return; - - await interaction.reply({ - content: `${constants.emojis.statuses.yes} Reminder \`${id}\` canceled!`, - ephemeral: true, - }); -}); -defineButton("cancelReminder", async (interaction, id = "") => { - const success = await cancelReminder(interaction, id); - if (!success) return; - - if (interaction.message.flags.has("Ephemeral")) { - await interaction.reply({ - content: `${constants.emojis.statuses.yes} Reminder canceled!`, - ephemeral: true, - }); - } else { - await interaction.message.edit({ - content: `~~${interaction.message.content}~~\n${ - constants.emojis.statuses.no - } Reminder canceled${ - interaction.user.id === interaction.message.interaction?.user.id ? "" : " by a mod" - }.`, - components: disableComponents(interaction.message.components), - }); - await interaction.deferUpdate(); - } -}); -async function cancelReminder(interaction: MessageComponentInteraction, id: string) { - if ( - interaction.user.id !== interaction.message.interaction?.user.id && - (!config.roles.mod || - !(interaction.member instanceof GuildMember - ? interaction.member.roles.resolve(config.roles.mod.id) - : interaction.member?.roles.includes(config.roles.mod.id))) - ) { - await interaction.reply({ - ephemeral: true, - content: `${constants.emojis.statuses.no} You don’t have permission to cancel this reminder!`, - }); - return false; - } - - const filtered = remindersDatabase.data.filter((reminder) => reminder.id !== id); - - if (filtered.length !== remindersDatabase.data.length - 1) { - if (!interaction.message.flags.has("Ephemeral")) - await interaction.message.edit({ - components: disableComponents(interaction.message.components), - }); - await interaction.reply({ - ephemeral: true, - content: `${constants.emojis.statuses.no} Could not find the reminder to cancel!`, - }); - return false; - } - - remindersDatabase.data = filtered; - return true; -} - -defineEvent("messageCreate", async (message) => { - if ( - message.guild?.id === config.guild.id && - message.interaction?.commandName == "bump" && - message.author.id === constants.users.disboard - ) { - remindersDatabase.data = [ - ...remindersDatabase.data.filter( - (reminder) => - !(reminder.id === SpecialReminders.Bump && reminder.user === client.user.id), - ), - { - channel: BUMPING_THREAD, - date: Date.now() + 7200000, - reminder: undefined, - id: SpecialReminders.Bump, - user: client.user.id, - }, - ]; - } -}); - -if (!remindersDatabase.data.find((reminder) => reminder.id === SpecialReminders.Weekly)) { - remindersDatabase.data = [ - ...remindersDatabase.data, - { - channel: config.channels.announcements?.id ?? "", - date: Date.now() + 3_600_000, - reminder: undefined, - id: SpecialReminders.Weekly, - user: client.user.id, - }, - ]; -} - -if (!remindersDatabase.data.find((reminder) => reminder.id === SpecialReminders.UpdateSACategory)) { - remindersDatabase.data = [ - ...remindersDatabase.data, - { - channel: config.channels.suggestions?.parent?.id ?? "", - date: Date.now(), - reminder: undefined, - id: SpecialReminders.UpdateSACategory, - user: client.user.id, - }, - ]; -} - -if ( - !remindersDatabase.data.find((reminder) => reminder.id === SpecialReminders.Bump) && - process.env.NODE_ENV === "production" -) { - remindersDatabase.data = [ - ...remindersDatabase.data, - { - channel: BUMPING_THREAD, - date: Date.now() + 3_600_000, - reminder: undefined, - id: SpecialReminders.Bump, - user: client.user.id, - }, - ]; -} - -if (!remindersDatabase.data.find((reminder) => reminder.id === SpecialReminders.BackupDatabases)) { - const { threads } = (await config.channels.mod?.threads.fetch()) ?? {}; - const channel = - threads?.find(({ name }) => name === "Scradd Database Backups") || - (await config.channels.mod?.threads.create({ - name: "Scradd Database Backups", - reason: "For database backups", - type: ChannelType.PrivateThread, - invitable: false, - autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, - })); - remindersDatabase.data = [ - ...remindersDatabase.data, - { - channel: channel?.id ?? "", - date: Date.now() + 3_600_000, - reminder: undefined, - id: SpecialReminders.BackupDatabases, - user: client.user.id, - }, - ]; -} diff --git a/modules/reminders/index.ts b/modules/reminders/index.ts new file mode 100644 index 000000000..11dd0d01e --- /dev/null +++ b/modules/reminders/index.ts @@ -0,0 +1,100 @@ +import { ApplicationCommandOptionType } from "discord.js"; +import config from "../../common/config.js"; +import constants from "../../common/constants.js"; +import { disableComponents } from "../../util/discord.js"; +import { defineButton, defineSelect, client, defineCommand, defineEvent } from "strife.js"; +import queueReminders from "./send.js"; +import { BUMPING_THREAD, SpecialReminders, remindersDatabase } from "./misc.js"; +import { cancelReminder, createReminder, listReminders } from "./management.js"; + +defineCommand( + { + name: "reminders", + description: "Commands to manage reminders", + censored: false, + subcommands: { + add: { + description: "Sets a reminder", + options: { + dms: { + type: ApplicationCommandOptionType.Boolean, + description: + "Send the reminder in DMs instead of this channel (defaults to true unless changed with /settings)", + }, + time: { + type: ApplicationCommandOptionType.String, + required: true, + description: + "How long until sending the reminder or a UNIX timestamp to send it at (within one minute)", + }, + reminder: { + type: ApplicationCommandOptionType.String, + required: true, + description: "Reminder to send", + maxLength: 125, + }, + }, + }, + list: { description: "View your reminders" }, + }, + }, + async (interaction) => { + if (interaction.options.getSubcommand(true) === "list") listReminders(interaction); + else createReminder(interaction); + }, +); + +defineSelect("cancelReminder", async (interaction) => { + const [id = ""] = interaction.values; + const success = await cancelReminder(interaction, id); + if (!success) return; + + await interaction.reply({ + content: `${constants.emojis.statuses.yes} Reminder \`${id}\` canceled!`, + ephemeral: true, + }); +}); +defineButton("cancelReminder", async (interaction, id = "") => { + const success = await cancelReminder(interaction, id); + if (!success) return; + + if (interaction.message.flags.has("Ephemeral")) { + await interaction.reply({ + content: `${constants.emojis.statuses.yes} Reminder canceled!`, + ephemeral: true, + }); + } else { + await interaction.message.edit({ + content: `~~${interaction.message.content}~~\n${ + constants.emojis.statuses.no + } Reminder canceled${ + interaction.user.id === interaction.message.interaction?.user.id ? "" : " by a mod" + }.`, + components: disableComponents(interaction.message.components), + }); + await interaction.deferUpdate(); + } +}); + +defineEvent("messageCreate", async (message) => { + if ( + message.guild?.id === config.guild.id && + message.interaction?.commandName == "bump" && + message.author.id === constants.users.disboard + ) { + remindersDatabase.data = [ + ...remindersDatabase.data.filter( + (reminder) => + !(reminder.id === SpecialReminders.Bump && reminder.user === client.user.id), + ), + { + channel: BUMPING_THREAD, + date: Date.now() + 7200000, + reminder: undefined, + id: SpecialReminders.Bump, + user: client.user.id, + }, + ]; + await queueReminders(); + } +}); diff --git a/modules/reminders/management.ts b/modules/reminders/management.ts new file mode 100644 index 000000000..f10d758e6 --- /dev/null +++ b/modules/reminders/management.ts @@ -0,0 +1,201 @@ +import { + ButtonStyle, + ComponentType, + GuildMember, + time, + TimestampStyles, + ChatInputCommandInteraction, + MessageComponentInteraction, +} from "discord.js"; +import constants from "../../common/constants.js"; +import censor, { badWordsAllowed } from "../automod/language.js"; +import { convertBase, parseTime } from "../../util/numbers.js"; +import { getSettings } from "../settings.js"; +import warn from "../punishments/warn.js"; +import { getLevelForXp, xpDatabase } from "../xp/misc.js"; +import { getUserReminders, remindersDatabase } from "./misc.js"; +import config from "../../common/config.js"; +import { disableComponents } from "../../util/discord.js"; +import queueReminders from "./send.js"; + +export async function listReminders(interaction: ChatInputCommandInteraction<"cached" | "raw">) { + const reminders = getUserReminders(interaction.user.id); + + if (!reminders.length) + return await interaction.reply({ + ephemeral: true, + content: `${constants.emojis.statuses.no} You don’t have any reminders set!`, + }); + + return await interaction.reply({ + ephemeral: true, + embeds: [ + { + color: + interaction.member instanceof GuildMember + ? interaction.member.displayColor + : undefined, + + author: { + icon_url: (interaction.member instanceof GuildMember + ? interaction.member + : interaction.user + ).displayAvatarURL(), + name: (interaction.member instanceof GuildMember + ? interaction.member + : interaction.user + ).displayName, + }, + footer: { + text: `${reminders.length} reminder${reminders.length === 1 ? "" : "s"}`, + }, + description: reminders + .map( + (reminder) => + `\`${reminder.id}\`) ${time( + new Date(reminder.date), + TimestampStyles.RelativeTime, + )}: <#${reminder.channel}> ${reminder.reminder}`, + ) + .join("\n"), + }, + ], + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + customId: "_cancelReminder", + type: ComponentType.StringSelect, + placeholder: "Cancel", + options: reminders.map((reminder) => ({ + value: reminder.id + "", + description: `${reminder.reminder}`.slice(0, 100), + label: reminder.id + "", + })), + }, + ], + }, + ], + }); +} + +export async function createReminder(interaction: ChatInputCommandInteraction<"cached" | "raw">) { + const reminders = getUserReminders(interaction.user.id); + const dms = interaction.options.getBoolean("dms") ?? getSettings(interaction.user).dmReminders; + const reminder = interaction.options.getString("reminder", true); + + if (!dms && !badWordsAllowed(interaction.channel)) { + const censored = censor(reminder); + + if (censored) { + await interaction.reply({ + ephemeral: true, + content: `${constants.emojis.statuses.no} ${ + censored.strikes < 1 ? "That's not appropriate" : "Language" + }!`, + }); + await warn( + interaction.user, + "Watch your language!", + censored.strikes, + `Used command ${interaction.toString()}`, + ); + return; + } + } + + if ( + reminders.length > + Math.ceil( + getLevelForXp( + Math.abs(xpDatabase.data.find(({ user }) => user === interaction.user.id)?.xp ?? 0), + ) * 0.3, + ) + + 5 + ) { + return await interaction.reply({ + ephemeral: true, + content: `${constants.emojis.statuses.no} You already have ${reminders.length} reminders set! Please cancel some or level up before setting any more.`, + }); + } + + const date = parseTime(interaction.options.getString("time", true).toLowerCase().trim()); + if (+date < Date.now() + 60_000 || +date > Date.now() + 31_536_000_000) { + return await interaction.reply({ + ephemeral: true, + content: `${constants.emojis.statuses.no} Could not parse the time! Make sure to pass in the value as so: \`1h30m\`, for example. Note that I can’t remind you sooner than 1 minute or later than 365 days.`, + }); + } + + const channel = dms + ? (await interaction.user.createDM().catch(() => {}))?.id + : interaction.channel?.id; + if (!channel) + return await interaction.reply({ + ephemeral: true, + content: `${constants.emojis.statuses.no} Your DMs are closed, so I can’t remind you!`, + }); + + const id = convertBase(Date.now() + "", 10, convertBase.MAX_BASE); + remindersDatabase.data = [ + ...remindersDatabase.data, + { channel, date: +date, reminder, user: interaction.user.id, id }, + ]; + await queueReminders(); + + await interaction.reply({ + ephemeral: dms, + content: `${constants.emojis.statuses.yes} I’ll remind you ${time( + date, + TimestampStyles.RelativeTime, + )}!`, + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + label: "Cancel", + customId: `${id}_cancelReminder`, + style: ButtonStyle.Danger, + }, + ], + }, + ], + }); +} + +export async function cancelReminder(interaction: MessageComponentInteraction, id: string) { + if ( + interaction.user.id !== interaction.message.interaction?.user.id && + (!config.roles.mod || + !(interaction.member instanceof GuildMember + ? interaction.member.roles.resolve(config.roles.mod.id) + : interaction.member?.roles.includes(config.roles.mod.id))) + ) { + await interaction.reply({ + ephemeral: true, + content: `${constants.emojis.statuses.no} You don’t have permission to cancel this reminder!`, + }); + return false; + } + + const filtered = remindersDatabase.data.filter((reminder) => reminder.id !== id); + + if (filtered.length !== remindersDatabase.data.length - 1) { + if (!interaction.message.flags.has("Ephemeral")) + await interaction.message.edit({ + components: disableComponents(interaction.message.components), + }); + await interaction.reply({ + ephemeral: true, + content: `${constants.emojis.statuses.no} Could not find the reminder to cancel!`, + }); + return false; + } + + remindersDatabase.data = filtered; + await queueReminders(); + return true; +} diff --git a/modules/reminders/misc.ts b/modules/reminders/misc.ts new file mode 100644 index 000000000..dab88516e --- /dev/null +++ b/modules/reminders/misc.ts @@ -0,0 +1,102 @@ +import type { Snowflake } from "discord.js"; +import Database from "../../common/database.js"; +import { ChannelType, ThreadAutoArchiveDuration } from "discord.js"; +import config from "../../common/config.js"; +import { client } from "strife.js"; +import queueReminders from "./send.js"; + +export enum SpecialReminders { + Weekly, + UpdateSACategory, + Bump, + RebootBot, + CloseThread, + LockThread, + Unban, + BackupDatabases, +} +export type Reminder = { + channel: Snowflake; + date: number; + reminder?: string | number; + user: Snowflake; + id: string | SpecialReminders; +}; + +export const BUMPING_THREAD = "881619501018394725", + COMMAND_ID = "947088344167366698"; + +export const remindersDatabase = new Database("reminders"); +await remindersDatabase.init(); + +export function getUserReminders(id: string) { + return remindersDatabase.data + .filter((reminder) => reminder.user === id) + .sort((one, two) => one.date - two.date); +} + +if (!remindersDatabase.data.find((reminder) => reminder.id === SpecialReminders.Weekly)) { + remindersDatabase.data = [ + ...remindersDatabase.data, + { + channel: config.channels.announcements?.id ?? "", + date: Date.now() + 302_400_000, + reminder: undefined, + id: SpecialReminders.Weekly, + user: client.user.id, + }, + ]; +} + +if (!remindersDatabase.data.find((reminder) => reminder.id === SpecialReminders.UpdateSACategory)) { + remindersDatabase.data = [ + ...remindersDatabase.data, + { + channel: config.channels.suggestions?.parent?.id ?? "", + date: Date.now(), + reminder: undefined, + id: SpecialReminders.UpdateSACategory, + user: client.user.id, + }, + ]; +} + +if ( + !remindersDatabase.data.find((reminder) => reminder.id === SpecialReminders.Bump) && + process.env.NODE_ENV === "production" +) { + remindersDatabase.data = [ + ...remindersDatabase.data, + { + channel: BUMPING_THREAD, + date: Date.now() + 3_600_000, + reminder: undefined, + id: SpecialReminders.Bump, + user: client.user.id, + }, + ]; +} + +if (!remindersDatabase.data.find((reminder) => reminder.id === SpecialReminders.BackupDatabases)) { + const { threads } = (await config.channels.mod?.threads.fetch()) ?? {}; + const channel = + threads?.find(({ name }) => name === "Scradd Database Backups") || + (await config.channels.mod?.threads.create({ + name: "Scradd Database Backups", + reason: "For database backups", + type: ChannelType.PrivateThread, + invitable: false, + autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, + })); + remindersDatabase.data = [ + ...remindersDatabase.data, + { + channel: channel?.id ?? "", + date: Date.now(), + reminder: undefined, + id: SpecialReminders.BackupDatabases, + user: client.user.id, + }, + ]; +} +await queueReminders(); diff --git a/modules/reminders/send.ts b/modules/reminders/send.ts new file mode 100644 index 000000000..249334ec2 --- /dev/null +++ b/modules/reminders/send.ts @@ -0,0 +1,197 @@ +import { client } from "strife.js"; +import { + remindersDatabase, + type Reminder, + SpecialReminders, + BUMPING_THREAD, + COMMAND_ID, +} from "./misc.js"; +import getWeekly, { getChatters } from "../xp/weekly.js"; +import { convertBase, nth } from "../../util/numbers.js"; +import { ChannelType, MessageFlags, TimestampStyles, time } from "discord.js"; +import constants from "../../common/constants.js"; +import { backupDatabases, cleanDatabaseListeners } from "../../common/database.js"; +import config from "../../common/config.js"; + +let nextReminder: NodeJS.Timeout | undefined; +export default async function queueReminders(): Promise { + if (nextReminder) clearTimeout(nextReminder); + + const interval = getNextInterval(); + if (interval === undefined) return; + + if (interval < 30_000) { + return await sendReminders(); + } else { + nextReminder = setTimeout(sendReminders, interval); + return nextReminder; + } +} + +async function sendReminders(): Promise { + if (nextReminder) clearTimeout(nextReminder); + + const { toSend, toPostpone } = remindersDatabase.data.reduce<{ + toSend: Reminder[]; + toPostpone: Reminder[]; + }>( + (acc, reminder) => { + acc[reminder.date - Date.now() < 60_000 ? "toSend" : "toPostpone"].push(reminder); + return acc; + }, + { toSend: [], toPostpone: [] }, + ); + remindersDatabase.data = toPostpone; + + const promises = toSend.map(async (reminder) => { + const channel = await client.channels.fetch(reminder.channel).catch(() => {}); + if (reminder.user === client.user.id) { + switch (reminder.id) { + case SpecialReminders.Weekly: { + if (!channel?.isTextBased()) return; + + const date = new Date(); + date.setUTCDate(date.getUTCDate() - 7); + const nextWeeklyDate = new Date(reminder.date); + nextWeeklyDate.setUTCDate(nextWeeklyDate.getUTCDate() + 7); + + const chatters = await getChatters(); + const message = await channel.send(await getWeekly(nextWeeklyDate)); + if (!chatters) return message; + const thread = await message.startThread({ + name: `🏆 Weekly Winners week of ${ + [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ][date.getUTCMonth()] || "" + } ${nth(date.getUTCDate(), { bold: false, jokes: false })}`, + reason: "To send all chatters", + }); + await thread.send(chatters); + return message; + } + case SpecialReminders.UpdateSACategory: { + if (channel?.type !== ChannelType.GuildCategory) return; + + remindersDatabase.data = [ + ...remindersDatabase.data, + { + channel: reminder.channel, + date: Number(Date.now() + 3_600_000), + reminder: undefined, + id: SpecialReminders.UpdateSACategory, + user: client.user.id, + }, + ]; + + const count = await fetch( + `${constants.urls.usercountJson}?date=${Date.now()}`, + ).then( + async (response) => + await response?.json<{ count: number; _chromeCountDate: string }>(), + ); + + return await channel?.setName( + `Scratch Addons - ${count.count.toLocaleString("en-us", { + compactDisplay: "short", + maximumFractionDigits: 1, + minimumFractionDigits: +(count.count > 999), + notation: "compact", + })} users`, + "Automated update to sync count", + ); + } + case SpecialReminders.Bump: { + if (!channel?.isTextBased()) return; + + remindersDatabase.data = [ + ...remindersDatabase.data, + { + channel: BUMPING_THREAD, + date: Date.now() + 1800000, + reminder: undefined, + id: SpecialReminders.Bump, + user: client.user.id, + }, + ]; + + return await channel.send({ + content: `🔔 @here the server!`, + allowedMentions: { parse: ["everyone"] }, + }); + } + case SpecialReminders.RebootBot: { + await cleanDatabaseListeners(); + process.emitWarning(`${client.user.tag} is killing the bot`); + // eslint-disable-next-line unicorn/no-process-exit -- This is how you restart the process on Railway. + process.exit(1); + } + case SpecialReminders.CloseThread: { + if (channel?.isThread()) await channel.setArchived(true, "Close requested"); + return; + } + case SpecialReminders.LockThread: { + if (channel?.isThread()) await channel.setLocked(true, "Lock requested"); + return; + } + case SpecialReminders.Unban: { + await config.guild.bans.remove( + String(reminder.reminder), + "Unbanned after set time period", + ); + return; + } + case SpecialReminders.BackupDatabases: { + if (!channel?.isTextBased()) return; + + remindersDatabase.data = [ + ...remindersDatabase.data, + { + channel: reminder.channel, + date: Number(Date.now() + 86_400), + reminder: undefined, + id: SpecialReminders.BackupDatabases, + user: client.user.id, + }, + ]; + + return backupDatabases(channel); + } + } + } + if (!channel?.isTextBased() || typeof reminder.reminder !== "string") return; + const silent = reminder.reminder.startsWith("@silent"); + const content = silent ? reminder.reminder.replace("@silent", "") : reminder.reminder; + await channel + .send({ + content: `🔔 ${ + channel.isDMBased() ? "" : `<@${reminder.user}> ` + }${content.trim()} (from ${time( + new Date(+convertBase(reminder.id + "", convertBase.MAX_BASE, 10)), + TimestampStyles.RelativeTime, + )})`, + allowedMentions: { users: [reminder.user] }, + flags: silent ? MessageFlags.SuppressNotifications : undefined, + }) + .catch(() => {}); + }); + await Promise.all(promises); + + return await queueReminders(); +} + +function getNextInterval() { + const reminder = [...remindersDatabase.data].sort((one, two) => one.date - two.date)[0]; + if (!reminder) return; + return reminder.date - Date.now(); +} diff --git a/modules/threads.ts b/modules/threads.ts index ffc715f69..f5c64eafc 100644 --- a/modules/threads.ts +++ b/modules/threads.ts @@ -16,8 +16,9 @@ import { } from "discord.js"; import constants from "../common/constants.js"; import { parseTime } from "../util/numbers.js"; -import { SpecialReminders, remindersDatabase } from "./reminders.js"; +import { SpecialReminders, remindersDatabase } from "./reminders/misc.js"; import { disableComponents } from "../util/discord.js"; +import queueReminders from "./reminders/send.js"; export const threadsDatabase = new Database<{ id: Snowflake; @@ -161,6 +162,7 @@ defineCommand( id: SpecialReminders[command === "close-in" ? "CloseThread" : "LockThread"], }, ]; + await queueReminders(); const type = command.split("-")[0]; await interaction.reply({ @@ -222,6 +224,8 @@ defineButton("cancelThreadChange", async (interaction, type) => { reminder.channel === interaction.channel?.id ), ); + await queueReminders(); + await interaction.reply(`${constants.emojis.statuses.yes} Canceled ${type}!`); } }); diff --git a/modules/xp/weekly.ts b/modules/xp/weekly.ts index 3feb598dd..1d308ea76 100644 --- a/modules/xp/weekly.ts +++ b/modules/xp/weekly.ts @@ -8,7 +8,7 @@ import { import { client } from "strife.js"; import config from "../../common/config.js"; import { nth } from "../../util/numbers.js"; -import { remindersDatabase, SpecialReminders } from "../reminders.js"; +import { remindersDatabase, SpecialReminders } from "../reminders/misc.js"; import { getFullWeeklyData, recentXpDatabase, xpDatabase } from "./misc.js"; import constants from "../../common/constants.js"; import { getCustomRole, qualifiesForRole } from "../roles.js";