From 4164f117da63c310dacd03ee93ac3cff5c3b73c7 Mon Sep 17 00:00:00 2001 From: dshepsis Date: Thu, 24 Mar 2022 02:33:55 -0400 Subject: [PATCH] Switch to guildConfig and finish url monitoring Finished the switch from Keyv to guild-config JSON files. More-or-less finished the implementation of URL monitoring and URL detection in embed-message. Added Replyable module to help with some util functions that could be used for both interactions and normal message replies. Added splitMessageRegex module, which is a substitue for DiscordJS/util.splitMessage which has a bug: https://github.com/discordjs/discord.js/issues/7674 Added paginatedReply module, which is a clean way to send information that may not fit in a single message. This is combined with splitMessageRegex for a relatively easy way to send unlimited-length messages (such as with /http-monitor list) without flooding a guild channel or bashing Discord's API. --- .eslintrc.json | 1 + commands/colors.mjs | 9 -- commands/command-util/Replyable.mjs | 44 +++++ .../command-util/awaitCommandConfirmation.mjs | 35 ++-- commands/command-util/awaitCommandReply.mjs | 9 +- commands/command-util/paginatedReply.mjs | 102 ++++++++++++ commands/command-util/roleSelector.mjs | 3 - commands/embedMessage.mjs | 79 +++++---- commands/httpMonitor.mjs | 152 ++++++++++++++---- commands/manageColors.mjs | 13 -- commands/managePrivileges.mjs | 40 ++--- commands/wiki.mjs | 3 +- deploy-commands.mjs | 4 +- index.mjs | 5 +- routines/monitorURLsForHTTPErrors.mjs | 86 +++++++++- transferKeyvToGuildConfig.mjs | 17 ++ util/deploy-permissions.mjs | 30 ++-- util/fetchStatusCode.mjs | 63 ++++++-- util/guildConfig.mjs | 7 +- util/importDir.mjs | 11 -- ...nageUrlsDB.mjs => manageMonitoredURLs.mjs} | 98 ++++++----- util/splitMessageRegex.mjs | 66 ++++++++ 22 files changed, 648 insertions(+), 229 deletions(-) create mode 100644 commands/command-util/Replyable.mjs create mode 100644 commands/command-util/paginatedReply.mjs create mode 100644 transferKeyvToGuildConfig.mjs rename util/{manageUrlsDB.mjs => manageMonitoredURLs.mjs} (76%) create mode 100644 util/splitMessageRegex.mjs diff --git a/.eslintrc.json b/.eslintrc.json index d55e75f..ead6fb2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,6 +9,7 @@ "ecmaVersion": 2022 }, "rules": { + "no-constant-condition": ["error", { "checkLoops": false }], "arrow-spacing": ["warn", { "before": true, "after": true }], "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], "comma-dangle": ["error", { diff --git a/commands/colors.mjs b/commands/colors.mjs index 685c02a..050a89c 100644 --- a/commands/colors.mjs +++ b/commands/colors.mjs @@ -1,14 +1,6 @@ import { createRoleSelector } from './command-util/roleSelector.mjs'; -// import Keyv from 'keyv'; import * as guildConfig from '../util/guildConfig.mjs'; -// Load configuration database. This will be used to find which color roles -// the current server has: -// const colorRolesDB = new Keyv( -// 'sqlite://database.sqlite', -// { namespace: 'colorRoles' } -// ); - export const { data, execute, @@ -16,7 +8,6 @@ export const { name: 'colors', description: 'Select your username color.', async rolesFromInteraction(interaction) { - // return colorRolesDB.get(interaction.guildId); return guildConfig.get(interaction.guildId, 'colorRoles'); }, sortByGuildOrder: true, diff --git a/commands/command-util/Replyable.mjs b/commands/command-util/Replyable.mjs new file mode 100644 index 0000000..38c5766 --- /dev/null +++ b/commands/command-util/Replyable.mjs @@ -0,0 +1,44 @@ +/** + * A monad for replying to either interactions or messages, and then potentially + * editing those replies. This is needed because the Interaction.reply and + * Message.reply methods are actually a little different in options. + */ +export class Replyable { + #message; + #interaction; + #useMessage; + #sentReply; + constructor({ message, interaction } = {}) { + if (message) { + this.#message = message; + this.#useMessage = true; + } + else if (interaction) { + this.#interaction = interaction; + this.#useMessage = false; + } + else { + throw new Error('When constructing a Replyable, you must include a message and/or interaction, but neither was received.'); + } + } + async reply(messageOptions) { + if (this.#useMessage) { + this.#sentReply = await this.#message.reply(messageOptions); + return this.#sentReply; + } + await this.#interaction.reply(messageOptions); + return this.#interaction.fetchReply(); + } + async editReply(messageOptions) { + if (this.#useMessage) { + return this.#sentReply.edit(messageOptions); + } + return this.#interaction.editReply(messageOptions); + } + getUser() { + if (this.#useMessage) { + return this.#message.author; + } + return this.#interaction.user; + } +} \ No newline at end of file diff --git a/commands/command-util/awaitCommandConfirmation.mjs b/commands/command-util/awaitCommandConfirmation.mjs index 26a4fad..3f939f6 100644 --- a/commands/command-util/awaitCommandConfirmation.mjs +++ b/commands/command-util/awaitCommandConfirmation.mjs @@ -1,4 +1,5 @@ import { MessageActionRow, MessageButton } from 'discord.js'; +import { Replyable } from './Replyable.mjs'; // User response codes: export const USER_CANCEL = Symbol('User pressed the cancel button'); @@ -8,8 +9,14 @@ export const USER_TIMEOUT = Symbol('User did not interact with the confirm or ca // Provides a declarative way to give the user of a command a confirm/cancel // dialogue (using Action Row Buttons) before continuing to execute it: export async function awaitCommandConfirmation({ - // The interaction object for the command which we're asking the user to confirm + // The interaction object for the command which we're asking the + // user to confirm. interaction, + // Optional. The message to which to reply with the confirmation message. This + // is an alternative to replying to the interaction. This is useful if the + // command has multiple steps and/or you want to give a button prompt after + // another message (either one of the bot's own messages, or someone else's): + messageToReplyTo, // The name of the command. Only used in message strings. commandName, // Optional. The content of the initial warning message presented to the @@ -17,7 +24,9 @@ export async function awaitCommandConfirmation({ warningContent = `Are you sure you want to use this '${commandName}' command?`, // Optional. The content of the message presented to the user if they press // the confirm button. Set this to null if you don't want a message sent - // upon pressing the confrim button. + // upon pressing the confirm button. This is useful if you want to do an + // async option and then send a message yourself via the buttonInteraction + // property of the return object. confirmContent = `Executing '${commandName}' command...`, // Optional. The content of the message presented to the user if they press // the cancel button. Set this to null if you don't want a message sent @@ -25,6 +34,8 @@ export async function awaitCommandConfirmation({ cancelContent = `'${commandName}' command cancelled.`, // Optional. The text of the confirm button. confirmButtonLabel = 'Confirm', + // Optional. The text of the cancel button. + cancelButtonLabel = 'Cancel', // Optional. The style of the confirm button. Options are: // - PRIMARY, a blurple button // - SECONDARY, a grey button @@ -52,16 +63,18 @@ export async function awaitCommandConfirmation({ // Create cancel button: .addComponents(new MessageButton() .setCustomId(cancelId) - .setLabel('Cancel') + .setLabel(cancelButtonLabel) .setStyle('SECONDARY'), ) ); - await interaction.reply({ + // Use this utility class to allow for generically replying/editing replies to + // both interactions and messages + const replyTo = new Replyable({ message: messageToReplyTo, interaction }); + const warningMessage = await replyTo.reply({ content: warningContent, components: [row], ephemeral, }); - const warningMessage = await interaction.fetchReply(); // Wait for the user to press a button, with the given time limit: const filter = warningInteraction => ( @@ -80,7 +93,7 @@ export async function awaitCommandConfirmation({ const content = `This '${commandName}' command timed out after ${ Math.floor(timeout_ms / 1000) } seconds. Please dismiss this message and use the command again if needed.`; - await interaction.editReply({ content, components: [], ephemeral: true }); + await replyTo.editReply({ content, components: [], ephemeral }); return { responseType: USER_TIMEOUT, botMessage: warningMessage, @@ -90,10 +103,10 @@ export async function awaitCommandConfirmation({ // User pressed the confirm button: if (buttonInteraction.customId === confirmId) { if (confirmContent !== null) { - await interaction.editReply({ + await replyTo.editReply({ content: confirmContent, components: [], - ephemeral: true, + ephemeral, }); } return { @@ -105,10 +118,10 @@ export async function awaitCommandConfirmation({ // User pressed the cancel button: if (buttonInteraction.customId === cancelId) { if (cancelContent !== null) { - await interaction.editReply({ + await replyTo.editReply({ content: cancelContent, components: [], - ephemeral: true, + ephemeral, }); } return { @@ -117,6 +130,6 @@ export async function awaitCommandConfirmation({ buttonInteraction, }; } - // This should never execute? + // This should never execute throw new Error(`Unknown confirmation action for '${commandName}'!`); } \ No newline at end of file diff --git a/commands/command-util/awaitCommandReply.mjs b/commands/command-util/awaitCommandReply.mjs index 3af250b..259bf4f 100644 --- a/commands/command-util/awaitCommandReply.mjs +++ b/commands/command-util/awaitCommandReply.mjs @@ -24,9 +24,9 @@ export async function awaitCommandReply({ // making one using requestReplyContent. useMessage, // Optional. The maximum number of characters allowed for the user's reply. - // If it's too long, an error message is presented to the user. +Infinity by - // default (i.e. no character limit). - maxLength = Infinity, + // If it's too long, an error message is presented to the user. 2000 by + // default. + maxLength = 2000, // Optional overMaxLengthContent = `Your message exceeded the maximum response length of ${maxLength} characters. Please try this '${commandName}' command again.`, } = {}) { @@ -57,9 +57,10 @@ export async function awaitCommandReply({ botMessage, }; } + // Successfully collected a valid user reply: return { responseType: USER_REPLY, - userReply: collected.first(), + userReply, botMessage, }; } diff --git a/commands/command-util/paginatedReply.mjs b/commands/command-util/paginatedReply.mjs new file mode 100644 index 0000000..2e9c7aa --- /dev/null +++ b/commands/command-util/paginatedReply.mjs @@ -0,0 +1,102 @@ +// import { ButtonInteraction } from 'discord.js'; +import { MessageEmbed, MessageActionRow, MessageButton } from 'discord.js'; + +export async function paginatedReply({ + contents, + replyable, +} = {}) { + const numPages = contents.length; + const contentEmbeds = contents.map( + str => new MessageEmbed().setDescription(str) + ); + // If there is only one page, do not include the page buttons: + if (numPages === 1) { + return replyable.reply({ embeds: contentEmbeds }); + } + let currentPage = 0; + const buttonOrder = [ + { + id: 'first-page', + label: '❚◀', + press() { currentPage = 0; }, + }, + { + id: 'previous-page', + label: '◀', + disabled: true, + press() { --currentPage; }, + }, + { + id: 'page-number', + label: `1 / ${numPages}`, + disabled: true, + }, + { + id: 'next-page', + label: '▶', + press() { ++currentPage; }, + }, + { + id: 'last-page', + label: '▶❚', + press() { currentPage = numPages - 1; }, + }, + ]; + const buttonData = Object.create(null); + const buttonComponents = []; + for (const button of buttonOrder) { + buttonData[button.id] = button; + const component = (new MessageButton() + .setCustomId(button.id) + .setLabel(button.label) + .setStyle(button.style ?? 'SECONDARY') + .setDisabled(button.disabled ?? false) + ); + button.component = component; + buttonComponents.push(component); + } + const row = new MessageActionRow().addComponents(buttonComponents); + const getPageResponse = page => { + buttonData['first-page'].component.setDisabled(page <= 0); + buttonData['previous-page'].component.setDisabled(page <= 0); + buttonData['next-page'].component.setDisabled(page >= numPages - 1); + buttonData['last-page'].component.setDisabled(page >= numPages - 1); + buttonData['page-number'].component.setLabel( + `${currentPage + 1} / ${numPages}` + ); + return { + embeds: [ + contentEmbeds[page], + ], + components: [row], + }; + }; + const botMessage = await replyable.reply(getPageResponse(currentPage)); + // make listener for buttons which changes the currentPage var and calls + // getPageResponse or w/e. This should be ez + + const userId = replyable.getUser().id; + const filter = buttonInteraction => buttonInteraction.user.id === userId; + const collector = botMessage.createMessageComponentCollector({ + filter, + idle: 5 * 60_000, + }); + collector.on('collect', buttonInteraction => { + const buttonId = buttonInteraction.customId; + buttonData[buttonId].press(); + buttonInteraction.update(getPageResponse(currentPage)); + }); + collector.on('end', () => { + for (const button of buttonOrder) { + button.component.setDisabled(); + } + const content = 'This paginated message has timed out. Please re-use the original command to see the other pages again.'; + // If the message was deleted, trying to edit it will throw: + try { + return replyable.editReply({ content }); + } + catch (error) { + return; + } + }); +} \ No newline at end of file diff --git a/commands/command-util/roleSelector.mjs b/commands/command-util/roleSelector.mjs index c1f61c2..76be9c1 100644 --- a/commands/command-util/roleSelector.mjs +++ b/commands/command-util/roleSelector.mjs @@ -198,14 +198,11 @@ export function createRoleSelector({ // GuildMemberRoleManager.set method to change the user's roles in a // single Discord API request: const setOfRoleIdsToSet = new Set(userRoles.keys()); - console.log('user currently has roles: ', Array.from(setOfRoleIdsToSet)); for (const roleId in selectableRoles) { if (roleIdToAdd === roleId) continue; - console.log('going to remove role: ', roleId); setOfRoleIdsToSet.delete(roleId); } setOfRoleIdsToSet.add(roleIdToAdd); - console.log('going to set roles: ', Array.from(setOfRoleIdsToSet)); await userRolesManager.set(Array.from(setOfRoleIdsToSet)); const customMessage = selectableRoles[roleIdToAdd].message; content = customMessage ?? `You're now <@&${roleIdToAdd}>!`; diff --git a/commands/embedMessage.mjs b/commands/embedMessage.mjs index 8ac9c3c..9df92f4 100644 --- a/commands/embedMessage.mjs +++ b/commands/embedMessage.mjs @@ -2,7 +2,10 @@ import { SlashCommandBuilder } from '@discordjs/builders'; import { MessageEmbed } from 'discord.js'; import { ChannelType } from 'discord-api-types/v9'; import { byName } from '../privilegeLevels.mjs'; -import { addUrlObjs } from '../util/manageUrlsDB.mjs'; +import { addUrlObjs } from '../util/manageMonitoredURLs.mjs'; +import { awaitCommandReply, USER_REPLY } from './command-util/awaitCommandReply.mjs'; +import { awaitCommandConfirmation, USER_CONFIRM } from './command-util/awaitCommandConfirmation.mjs'; + export const data = (new SlashCommandBuilder() .setName('embed-message') @@ -19,36 +22,15 @@ export async function execute(interaction) { const channel = ( interaction.options.getChannel('channel') ?? interaction.channel ); - { - const content = `Please reply to this message with the contents you want to embed in ${channel}.`; - await interaction.reply({ content }); - } - const botMessage = await interaction.fetchReply(); - const REPLY_TIME_LIMIT = 30000; // milliseconds - const filter = message => ( - (botMessage.id === message?.reference?.messageId) - && (interaction.user.id === message?.author.id) - ); - let userReply; - try { - const collected = await interaction.channel.awaitMessages({ - filter, - max: 1, - time: REPLY_TIME_LIMIT, - errors: ['time'], - }); - userReply = collected.first(); - } - catch (error) { - const content = `This \`/embed-message\` command timed out after ${Math.floor(REPLY_TIME_LIMIT / 1000)} seconds. Please dismiss this message and use the command again if needed.`; - try { - // This may error if the bot's reply was deleted: - return interaction.editReply({ content }); - } - catch (editError) { - return null; - } + const { responseType, userReply } = await awaitCommandReply({ + interaction, + commandName: 'embed-message', + timeout_ms: 30_000, + requestReplyContent: `Please reply to this message with the contents you want to embed in ${channel}.`, + }); + if (responseType !== USER_REPLY) { + return; } const userContent = userReply.content; @@ -63,7 +45,7 @@ export async function execute(interaction) { // HTTPS request is made to them. This helps users know when they need to // edit their embed messages to fix broken links: let anyUrls = false; - const urlRegex = /\[(?[^\]]+)\]\((?https[^)]+)\)|(?https:\/\/\S+)/g; + const urlRegex = /\[(?[^\]]+)\]\((?https?[^)]+)\)|(?https?:\/\/\S+)/g; const urlObjsToAdd = []; for (const match of userContent.matchAll(urlRegex)) { anyUrls = true; @@ -85,15 +67,32 @@ export async function execute(interaction) { }; urlObjsToAdd.push(urlObj); } - let urlsMonitoredMsg = ''; - if (anyUrls) { - urlsMonitoredMsg = '\nAll URLs in the embed will be periodically checked for HTTP errors. You can manage this with the `/http-monitor` command.'; - console.log('updating urlObjs'); - await addUrlObjs(interaction.guildId, urlObjsToAdd); - console.log('updating urlObjs 4'); - } - { - const content = `Reply sent to ${channel}: ${embedMsg.url}${urlsMonitoredMsg}`; + if (!anyUrls) { + const content = `Reply sent to ${channel}: <${embedMsg.url}>`; return userReply.reply({ content }); } + // If there were any URLs in the message we just embeded, ask the user if they + // want to monitor these URLs. + const { + responseType: buttonResponseType, + buttonInteraction, + } = await awaitCommandConfirmation({ + interaction, + messageToReplyTo: userReply, + commandName: 'embed-message', + warningContent: `Reply sent to ${channel}: <${embedMsg.url}>.\nSome URL's were found in the message. Do you want to add them to the list of URLs monitored for HTTP errors? You can manage these using the \`/http-monitor\` command. If they're already being monitored, the existing entries will be updated to notify you in this channel.`, + buttonStyle: 'PRIMARY', + confirmContent: null, + confirmButtonLabel: 'Yes, monitor the URLs in my message.', + cancelButtonLabel: 'No, don\'t add URL monitoring.', + }); + if (buttonResponseType !== USER_CONFIRM) { + // If the user pressed the cancel button or let the confirmation dialog + // time out, just leave in-place the default replies of + // awaitCommandConfirmation. + return buttonInteraction; + } + await addUrlObjs(interaction.guildId, urlObjsToAdd); + const content = 'All URLs in the embed will be periodically checked for HTTP errors. You can manage this with the `/http-monitor` command.'; + return buttonInteraction.update({ content, components: [] }); } \ No newline at end of file diff --git a/commands/httpMonitor.mjs b/commands/httpMonitor.mjs index 12d725e..1ee052a 100644 --- a/commands/httpMonitor.mjs +++ b/commands/httpMonitor.mjs @@ -4,7 +4,11 @@ import { SlashCommandBuilder } from '@discordjs/builders'; import { byName } from '../privilegeLevels.mjs'; -import { setUrlEnabled, setUrlsEnabled, getUrlObjByUrl, getUrlObjsForGuild, addUrlObjs, deleteUrlObj, overwriteUrlObj } from '../util/manageUrlsDB.mjs'; +import * as manageUrls from '../util/manageMonitoredURLs.mjs'; +import { getReportStr } from '../routines/monitorURLsForHTTPErrors.mjs'; +import { splitMessageRegex } from '../util/splitMessageRegex.mjs'; +import { Replyable } from './command-util/Replyable.mjs'; +import { paginatedReply } from './command-util/paginatedReply.mjs'; // Used as choices for the re-enable and disable subcommands const scopeChoices = [ @@ -25,7 +29,9 @@ const degreeChoices = [ // For an array of urlObjs, returns a human readable message describing all of // them. The guild object is required to determine the tags corresponding to // the userIds in each urlObj -async function urlObjsToHumanReadableStr(urlObjs, guild) { +async function urlObjsToHumanReadableStr(urlObjs, guild, { + verbose = true, +} = {}) { // Get all of the user objects for all of the userIds for all of the urlObjs, // which are used in building the messages: const userIdSet = new Set(); @@ -43,18 +49,27 @@ async function urlObjsToHumanReadableStr(urlObjs, guild) { // Turn each url object into a human-readable message: for (const urlObj of urlObjs) { const escapedURL = '`' + urlObj.url.replaceAll('`', '\\`') + '`'; - const strLines = [`• URL ${escapedURL}${urlObj.enabled ? '' : ' (disabled)'}, is being monitored in the following channels:`]; + const objHeader = (verbose ? + `• URL ${escapedURL}${urlObj.enabled ? '' : ' (disabled)'}, is being monitored in the following channels:` + : `• ${escapedURL}${urlObj.enabled ? '' : '(disabled)'}` + ); + const strLines = [objHeader]; const notifyChannels = urlObj.notifyChannels; for (const channelId in notifyChannels) { const notifyObj = notifyChannels[channelId]; const userTags = notifyObj.userIds.map(userId => memberMap.get(userId).user.tag ); - strLines.push(` • <#${channelId}>${('info' in notifyObj) ? `, with note "${notifyObj.info}"` : ''}, with the following users being notified: ${userTags.join(', ')}`); + const line = (verbose ? + `└── <#${channelId}>${(notifyObj.info) ? `, with note "${notifyObj.info}"` : ''}, with the following users being notified: ${userTags.join(', ')}` + : `└── <#${channelId}>${(notifyObj.info) ? `"${notifyObj.info}"` : ''}: ${userTags.join(', ')}` + ); + strLines.push(line); } infoStrings.push(strLines.join('\n')); } - return infoStrings.join('\n\n'); + const infoSep = verbose ? '\n\n' : '\n'; + return infoStrings.join(infoSep); } export const data = (new SlashCommandBuilder() @@ -99,7 +114,25 @@ export const data = (new SlashCommandBuilder() ) .addStringOption(option => option .setName('url') - .setDescription('Which URL to disable (only checked if scope is SINGLE URL)') + .setDescription('Which URL to list (only checked if scope is SINGLE URL)') + ) + ) + .addSubcommand(subcommand => subcommand + .setName('test') + .setDescription('Immediately test a group of URLs currently being monitored for errors') + .addStringOption(option => option + .setName('scope') + .setDescription('Which group of URLs to test') + .setChoices(scopeChoices) + .setRequired(true) + ) + .addBooleanOption(option => option + .setName('errors-only') + .setDescription('If true, report only URLs which result in an error. Else, include all results.') + ) + .addStringOption(option => option + .setName('url') + .setDescription('Which URL to test (only checked if scope is SINGLE URL)') ) ) .addSubcommand(subcommand => subcommand @@ -170,7 +203,7 @@ export async function execute(interaction) { const content = 'You must specify a URL to re-enable monitoring for!'; return interaction.reply({ content }); } - const urlObj = await setUrlEnabled({ guildId, url }); + const urlObj = await manageUrls.setUrlEnabled({ guildId, url }); const escapedURL = '`' + url.replaceAll('`', '\\`') + '`'; const content = ((urlObj === undefined) ? @@ -185,7 +218,7 @@ export async function execute(interaction) { const content = `Unrecognized scope "${scope}"!`; return interaction.reply({ content }); } - await setUrlsEnabled({ + await manageUrls.setUrlsEnabled({ guildId, urlObjFilterFun, }); @@ -202,7 +235,7 @@ export async function execute(interaction) { const content = 'You must specify a URL to disable monitoring for!'; return interaction.reply({ content }); } - const urlObj = await setUrlEnabled({ + const urlObj = await manageUrls.setUrlEnabled({ guildId, url, enabled: false, @@ -220,7 +253,7 @@ export async function execute(interaction) { const content = `Unrecognized scope "${scope}"!`; return interaction.reply({ content }); } - await setUrlsEnabled({ + await manageUrls.setUrlsEnabled({ guildId, urlObjFilterFun, enabled: false, @@ -237,7 +270,7 @@ export async function execute(interaction) { const content = 'You must specify a URL to list information for!'; return interaction.reply({ content }); } - const urlObj = await getUrlObjByUrl(guildId, url); + const urlObj = await manageUrls.getUrlObjByUrl(guildId, url); const escapedURL = '`' + url.replaceAll('`', '\\`') + '`'; const content = ((urlObj === undefined) ? `The given url ${escapedURL} is not being monitored.` @@ -251,20 +284,90 @@ export async function execute(interaction) { const content = `Unrecognized scope "${scope}"!`; return interaction.reply({ content }); } - const allUrlObjs = await getUrlObjsForGuild(guildId); + const allUrlObjs = await manageUrls.getUrlObjsForGuild(guildId); const filteredUrlObjs = (urlObjFilterFun === null ? allUrlObjs : allUrlObjs.filter(urlObjFilterFun) ); - const content = ((filteredUrlObjs.length === 0) ? + const numObjs = filteredUrlObjs.length; + const content = ((numObjs === 0) ? `No URLs are being currently monitored under the scope "${scope}".` - : await urlObjsToHumanReadableStr(filteredUrlObjs, guild) + : await urlObjsToHumanReadableStr( + filteredUrlObjs, + guild, + { verbose: false }// numObjs <= 5 } + ) ); - return interaction.reply({ content }); + + // For broader scopes (especially ALL), the resulting response is likely to + // exceed the character limit, so we use splitMessageRegex. This is my + // replacement for discord.js.util#splitMessage while it is affected by + // https://github.com/discordjs/discord.js/issues/7674 + const contents = splitMessageRegex(content, { regex: /\n+(?! {4})/g }); + return paginatedReply({ + contents, + replyable: new Replyable({ interaction }), + }); + // const splitContent = splitMessageRegex(content, { regex: /\n+(?! {4})/g }); + // await interaction.reply({ content: splitContent[0] }); + // for (let i = 1, len = splitContent.length; i < len; ++i) { + // await interaction.followUp({ content: splitContent[i] }); + // } + // return; + } + if (subcommandName === 'test') { + const scope = interaction.options.getString('scope'); + const errorsOnly = interaction.options.getBoolean('errors-only') ?? true; + let urlObjsToTest; + if (scope === 'SINGLE URL') { + const url = interaction.options.getString('url'); + if (url === null) { + const content = 'You must specify a URL to test!'; + return interaction.reply({ content }); + } + // The getReportStr function accepts plain URL strings as well as urlObjs. + // This means we can easily test a URL even if it isn't actually being + // monitored. + urlObjsToTest = [url]; + } + else { + // If we're testing a group of URLs: + const urlObjFilterFun = filterFuns[scope]; + if (urlObjFilterFun === undefined) { + const content = `Unrecognized scope "${scope}"!`; + return interaction.reply({ content }); + } + const allUrlObjs = await manageUrls.getUrlObjsForGuild(guildId); + urlObjsToTest = (urlObjFilterFun === null ? + allUrlObjs + : allUrlObjs.filter(urlObjFilterFun) + ); + } + if (urlObjsToTest.length === 0) { + const content = `No URLs are being currently monitored under the scope "${scope}".`; + return interaction.reply({ content }); + } + // Making a lot of HTTPS requests can actually take a long time, so we use + // deferReply to give us up to 15 minutes to finish: + await interaction.deferReply({ ephemeral: false }); + const content = await getReportStr(urlObjsToTest, { errorsOnly }); + + // For broader scopes (especially ALL), the resulting response is likely to + // exceed the character limit, so we use discord.js.util#splitMessage + // and interaction.followUp to send multiple messages: + const splitContent = splitMessageRegex(content, { regex: /\n+(?! {4})/g }); + + // Because deferReply was used, editReply has to be used here: + await interaction.editReply({ content: splitContent[0], ephemeral: false }); + for (let i = 1, len = splitContent.length; i < len; ++i) { + await interaction.followUp({ content: splitContent[i] }); + } + return; } + if (subcommandName === 'add') { const url = interaction.options.getString('url'); - const preexistingUrlObj = await getUrlObjByUrl(guildId, url); + const preexistingUrlObj = await manageUrls.getUrlObjByUrl(guildId, url); const info = interaction.options.getString('info'); const channelIdToNotify = ( @@ -281,7 +384,7 @@ export async function execute(interaction) { }, }, }; - await addUrlObjs(guildId, [urlObj]); + await manageUrls.addUrlObjs(guildId, [urlObj]); const escapedURL = '`' + url.replaceAll('`', '\\`') + '`'; const content = (preexistingUrlObj === undefined ? `HTTP error monitoring was added for ${escapedURL}.` @@ -294,12 +397,8 @@ export async function execute(interaction) { const escapedURL = '`' + url.replaceAll('`', '\\`') + '`'; const degree = interaction.options.getString('degree'); - // 'FOR ME IN THIS CHANNEL', - // 'FOR ME IN ALL CHANNELS', - // 'FOR THIS CHANNEL', - // 'REMOVE COMPLETELY', if (degree === 'REMOVE COMPLETELY') { - const deleted = await deleteUrlObj(guildId, url); + const deleted = await manageUrls.deleteUrlObj(guildId, url); const content = (deleted ? `The URL ${escapedURL} is no longer being monitored.` : `The URL ${escapedURL} was already not being monitored. No changes have been made.` @@ -309,7 +408,7 @@ export async function execute(interaction) { // If one of the other degrees are used, first request the existing urlObj // for the given url, then modify it and pass it back to manageUrlsDB: - const currentUrlObj = await getUrlObjByUrl(guildId, url); + const currentUrlObj = await manageUrls.getUrlObjByUrl(guildId, url); const notifyChannels = currentUrlObj.notifyChannels; if (currentUrlObj === undefined) { const content = `The URL ${escapedURL} was already not being monitored. No changes have been made.`; @@ -333,7 +432,7 @@ export async function execute(interaction) { else { userIds.splice(index, 1); } - await overwriteUrlObj(guildId, currentUrlObj); + await manageUrls.overwriteUrlObj(guildId, currentUrlObj); const content = `You will no longer be notified of errors for the URL ${escapedURL} in this channel.`; return interaction.reply({ content }); } @@ -362,7 +461,7 @@ export async function execute(interaction) { const content = `You were already not being notified for errors for the URL ${escapedURL} in any channels. No changes have been made.`; return interaction.reply({ content }); } - await overwriteUrlObj(guildId, currentUrlObj); + await manageUrls.overwriteUrlObj(guildId, currentUrlObj); const content = `You will no longer be notified of errors for the URL ${escapedURL} in any channel.`; return interaction.reply({ content }); } @@ -373,10 +472,9 @@ export async function execute(interaction) { return interaction.reply({ content }); } delete notifyChannels[channelId]; - await overwriteUrlObj(guildId, currentUrlObj); + await manageUrls.overwriteUrlObj(guildId, currentUrlObj); const content = `Notifications for errors for the URL ${escapedURL} will no longer be posted in this channel.`; return interaction.reply({ content }); - } } const content = `Unrecognized sub-command "${subcommandName}".`; diff --git a/commands/manageColors.mjs b/commands/manageColors.mjs index 800317e..0c22bbc 100644 --- a/commands/manageColors.mjs +++ b/commands/manageColors.mjs @@ -1,38 +1,27 @@ import { SlashCommandBuilder } from '@discordjs/builders'; -// import Keyv from 'keyv'; import { byName } from '../privilegeLevels.mjs'; import * as guildConfig from '../util/guildConfig.mjs'; - -// Load configuration database. This will be used to find which color roles -// the current server has: -// const colorRolesDB = new Keyv('sqlite://database.sqlite', { namespace: 'colorRoles' }); -// colorRolesDB.on('error', err => console.log('Connection Error when searching for colorRolesDB', err)); - const ALREADY_PRESENT = Symbol('Color role is already present in this guild.'); async function addColorRole(guildId, role, message) { - // const guildColorRoles = await colorRolesDB.get(guildId); const guildColorRoles = await guildConfig.get(guildId, 'colorRoles'); const roleData = { name: role.name, message, }; if (guildColorRoles === undefined) { - // return colorRolesDB.set(guildId, { [role.id]: roleData }); return guildConfig.set(guildId, 'colorRoles', { [role.id]: roleData }); } if (role.id in guildColorRoles) { return ALREADY_PRESENT; } guildColorRoles[role.id] = roleData; - // return colorRolesDB.set(guildId, guildColorRoles); return guildConfig.set(guildId, 'colorRoles', guildColorRoles); } const NOT_PRESENT = Symbol('Color role is not present in this guild.'); const NO_ROLES = Symbol('This guild has no color roles.'); async function removeColorRole(guildId, role) { - // const guildColorRoles = await colorRolesDB.get(guildId); const guildColorRoles = await guildConfig.get(guildId, 'colorRoles'); if (guildColorRoles === undefined) { return NO_ROLES; @@ -41,13 +30,11 @@ async function removeColorRole(guildId, role) { return NOT_PRESENT; } delete guildColorRoles[role.id]; - // return colorRolesDB.set(guildId, guildColorRoles); return guildConfig.set(guildId, 'colorRoles', guildColorRoles); } async function getColorRolesStr(interaction) { const guild = interaction.guild; - // const guildColorRoles = await colorRolesDB.get(guild.id); const guildColorRoles = await guildConfig.get(guild.id, 'colorRoles'); if (guildColorRoles === undefined) return NO_ROLES; const roleIds = Object.keys(guildColorRoles); diff --git a/commands/managePrivileges.mjs b/commands/managePrivileges.mjs index 1d2baea..be729a2 100644 --- a/commands/managePrivileges.mjs +++ b/commands/managePrivileges.mjs @@ -1,17 +1,18 @@ -import Keyv from 'keyv'; import { SlashCommandBuilder } from '@discordjs/builders'; import { byOrder, asChoices, MASTER_USER_ONLY } from '../privilegeLevels.mjs'; -// Load configuration database. This will be used to find which privilege -// privilege levels are associated with which roles in this guild -const privilegedRolesDB = new Keyv( - 'sqlite://database.sqlite', - { namespace: 'privilegedRoles' } -); -privilegedRolesDB.on('error', err => console.log( - 'Connection Error when searching for privilegedRolesDB', - err -)); +import * as guildConfig from '../util/guildConfig.mjs'; +/** Load privileged roles data from guild-config directory */ +async function getPrivilegedRoles(guildId) { + return guildConfig.get( + guildId, + 'privilegedRoles' + ); +} +/** Write privileged roles data to guild-config directory */ +async function setPrivilegedRoles(guildId, guildPrivilegeLevels) { + return guildConfig.set(guildId, 'privilegedRoles', guildPrivilegeLevels); +} let deployPermissions; const ALREADY_ASSOCIATED = Symbol('This role is already associated with this privilege level in this guild.'); @@ -24,18 +25,18 @@ async function associateRoleWithPrivilegeLevel({ deployPermissions = await import('../util/deploy-permissions.mjs'); } const guildId = guild.id; - const guildPrivilegeLevels = await privilegedRolesDB.get(guildId); + const guildPrivilegeLevels = await getPrivilegedRoles(guildId); if (guildPrivilegeLevels === undefined) { - return privilegedRolesDB.set(guildId, { [privilegeLevelName]: role.id }); + return setPrivilegedRoles(guildId, { [privilegeLevelName]: role.id }); } if (privilegeLevelName in guildPrivilegeLevels) { return ALREADY_ASSOCIATED; } guildPrivilegeLevels[privilegeLevelName] = role.id; - // Make sure to wait for the DB to be updated before attempting to deploy + // Make sure to wait for the file to be updated before attempting to deploy // the updated permissions: - await privilegedRolesDB.set(guildId, guildPrivilegeLevels); + await setPrivilegedRoles(guildId, guildPrivilegeLevels); // Get command id mapping from guild data: const commandNameToId = Object.create(null); @@ -57,7 +58,7 @@ async function removeAssociationsFromPrivilegeLevel({ privilegeLevelName, } = {}) { const guildId = guild.id; - const guildPrivilegeLevels = await privilegedRolesDB.get(guildId); + const guildPrivilegeLevels = await getPrivilegedRoles(guildId); if (guildPrivilegeLevels === undefined) { return NO_PRIVILEGES; } @@ -68,7 +69,8 @@ async function removeAssociationsFromPrivilegeLevel({ // Make sure to wait for the DB to be updated before attempting to deploy // the updated permissions: - await privilegedRolesDB.set(guildId, guildPrivilegeLevels); + await guildConfig.set(guildId, 'privilegedRoles', guildPrivilegeLevels); + await setPrivilegedRoles(guildId, guildPrivilegeLevels); // Get command id mapping from guild data: const commandNameToId = Object.create(null); @@ -84,7 +86,7 @@ async function removeAssociationsFromPrivilegeLevel({ } async function getPrivilegeLevelAssociationsString(guildId) { - const guildPrivilegeLevels = await privilegedRolesDB.get(guildId); + const guildPrivilegeLevels = await getPrivilegedRoles(guildId); const tHead = '__**Associated Role - Privilege Level Name - Description**__'; let tBody; if (guildPrivilegeLevels === undefined) { @@ -112,7 +114,7 @@ async function getRoleIdAssociatedWithPrivilegeLevel({ guild, privilegeLevelName, } = {}) { - const guildPrivilegeLevels = await privilegedRolesDB.get(guild.id); + const guildPrivilegeLevels = await getPrivilegedRoles(guild.id); return guildPrivilegeLevels[privilegeLevelName]; } diff --git a/commands/wiki.mjs b/commands/wiki.mjs index 66016c3..17a34a0 100644 --- a/commands/wiki.mjs +++ b/commands/wiki.mjs @@ -26,13 +26,12 @@ export async function execute(interaction) { response = (await fetchJSON(queryURL)).query.search[0]; } catch (e) { - console.log(e); + console.error(e); const content = 'Oops! Looks like the wiki\'s API is down! Try checking the wiki directly: https://okami.speedruns.wiki/'; return interaction.reply({ content }); } if (response === undefined) { // If no matching pages are found, direct the user to the wiki's search page: - // @TODO WARNING: This code may still be affected by this bug: https://github.com/discordjs/discord.js/issues/7373 const escapedQuery = Util.escapeMarkdown(query).replaceAll('`', '\\`'); const content = `Didn't find any pages with titles matching "${escapedQuery}".\nTry this wiki search link instead: https://okami.speedruns.wiki/index.php?search=${encodeURIComponent(query)}`; return interaction.reply({ content }); diff --git a/deploy-commands.mjs b/deploy-commands.mjs index bf04016..1327ab3 100644 --- a/deploy-commands.mjs +++ b/deploy-commands.mjs @@ -19,7 +19,6 @@ const commandNameToMinPrivs = Object.create(null); // a command with setDefaultPermission(false), then also read its required // privileges: for (const command of commands) { - // const command = require(`./commands/${file}`); commandData.push(command.data.toJSON()); const usableByDefault = command.data.defaultPermission ?? true; @@ -53,8 +52,7 @@ const rest = new REST({ version: '9' }).setToken(token); for (const command of response) { commandNameToId[command.name] = command.id; } - // Make sure that the Keyv store has been updated: - // await updateMetadataPromise; + // Make sure that the guild-config file has been updated: await deployPermissions({ guildId, commandNameToId, diff --git a/index.mjs b/index.mjs index 93ef430..ee1dd26 100644 --- a/index.mjs +++ b/index.mjs @@ -11,7 +11,6 @@ const client = new Client({ intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_ // Import each command module and save it to a collection client.commands = new Collection(); -// const commands = await importDir(resolve('./commands/')); const commands = await importDir(pkgRelPath('./commands/')); for (const command of commands) { client.commands.set(command.data.name, command); @@ -57,6 +56,8 @@ for (const routine of routines) { if (RUN_ON_STARTUP) { loopTimeout(); } - timeoutIds[index] = setTimeout(loopTimeout, routine.interval_ms); + else { + timeoutIds[index] = setTimeout(loopTimeout, routine.interval_ms); + } ++index; } \ No newline at end of file diff --git a/routines/monitorURLsForHTTPErrors.mjs b/routines/monitorURLsForHTTPErrors.mjs index b599de8..1948ab6 100644 --- a/routines/monitorURLsForHTTPErrors.mjs +++ b/routines/monitorURLsForHTTPErrors.mjs @@ -1,5 +1,5 @@ -import { fetchStatusCode } from '../util/fetchStatusCode.mjs'; -import { getEnabledUrlObjsForGuild, setUrlsEnabled } from '../util/manageUrlsDB.mjs'; +import { fetchResponseChain, fetchStatusCode } from '../util/fetchStatusCode.mjs'; +import { getEnabledUrlObjsForGuild, setUrlsEnabled } from '../util/manageMonitoredURLs.mjs'; async function sendMessageToGuildChannel({ guild, @@ -10,7 +10,87 @@ async function sendMessageToGuildChannel({ return channel.send({ content }); } -// Check the status codes for all of the URLs stored in the DB for the given +/** + * @typedef {Object} NotifyChannelInfo An object storing information about the + * notification to send to a given channel + * @prop {string[]} userIds Which users to notify in the given channel + * @prop {string} [info] An optional extra message to include with notifications + * + * @typedef {Object} UrlObj An object storing information about a URL being + * monitored by AutoRide via the monitorURLsForHTTPErrors routine. + * @prop {string} url The URL being monitored + * @prop {boolean} enabled Whether the URL is currently being monitored. If + * false, monitoring is temporarily disabled for the url until re-enabled via + * the http-monitor re-enable command + * @prop {Object.} notifyChannels An object + * mapping from channel Ids to objects containing information about the + * notification message to send to that channel in the event of an error. + */ + +/** + * Similar to reportStatusCodesForGuild, except it directly takes an array of + * urlObjs and returns a string summarizing both the normal responses and the + * errors, ignoring the notifyChannels property. + * + * @param {(string | UrlObj)[]} urlObjs An array of url strings or UrlObjs to check + * @param {Object} options + * @param {boolean} [options.errorsOnly=false] If true, include lines only for + * urls which result in an error. Otherwise, include a line for all urls. If + * true and no urls result in an error, a special-case message is returned. + * @returns {Promise} A string message with a human-readable line for each urlObj + * giving information about the response received from that url. + */ +export async function getReportStr(urlObjs, { errorsOnly = false } = {}) { + if (urlObjs.length === 0) { + return 'No URLs were given to be tested'; + } + const urls = urlObjs.map(o => (typeof o === 'string') ? o : o.url); + const responsePromises = urls.map(fetchResponseChain); + const responseChains = await Promise.all(responsePromises); + + const outLines = []; + + for (let i = 0, len = responseChains.length; i < len; ++i) { + const chain = responseChains[i]; + const url = urls[i]; + const escapedURL = '`' + url.replaceAll('`', '\\`') + '`'; + + // A resolved promise indicates that a response was received, but that + // response may have been an HTTP error, so filter out acceptable status + // codes: + let line = `• ${escapedURL} `; + const chainLen = chain.length; + const endResponse = chain[chainLen - 1]; + if (chainLen > 1) { + const endURL = endResponse.url; + line += `redirects to ${'`' + endURL.replaceAll('`', '\\`') + '`'} which `; + } + const httpResponseCode = endResponse.result; + if (httpResponseCode === 200) { + if (errorsOnly) { + // If the errorsOnly option is truthy, do not include a line for URLs + // which end in an OK result: + continue; + } + line += 'is OK.'; + } + else if (typeof httpResponseCode === 'number') { + line += `results in an HTTP ${httpResponseCode} error.`; + } + else { + line += `results in a "${httpResponseCode}" Node request error.`; + } + outLines.push(line); + } + if (outLines.length === 0) { + return 'No HTTP errors were found for any of the given URLs'; + } + return outLines.join('\n'); +} + +// @TODO Fix this to use fetchResponseChain instead!!! This currentlywon't check if a +// redirect leads to a 404!!! +// Check the status codes for all of the URLs stored in the config for the given // guild. Then, if any of them are error codes, send a message to the // corresponding channel. export async function reportStatusCodesForGuild(client, guildId) { diff --git a/transferKeyvToGuildConfig.mjs b/transferKeyvToGuildConfig.mjs new file mode 100644 index 0000000..d4fba95 --- /dev/null +++ b/transferKeyvToGuildConfig.mjs @@ -0,0 +1,17 @@ +import Keyv from 'keyv'; +import * as guildConfig from './util/guildConfig.mjs'; + +const guildId = '930249527577935893'; +const namespaces = { + colorRoles: 'colorRoles', + privilegedRoles: 'privilegedRoles', + guildResources: 'monitoredURLs', +}; +for (const sourceNamespace in namespaces) { + const keyvDB = new Keyv( + 'sqlite://database.sqlite', + { namespace: sourceNamespace } + ); + const sourceObj = await keyvDB.get(guildId); + await guildConfig.set(guildId, namespaces[sourceNamespace], sourceObj); +} \ No newline at end of file diff --git a/util/deploy-permissions.mjs b/util/deploy-permissions.mjs index 7ad2405..29b7e21 100644 --- a/util/deploy-permissions.mjs +++ b/util/deploy-permissions.mjs @@ -11,9 +11,8 @@ // files. Then, in each server the bot operates in, the manage-privileges // command is used to assign each privilege level a role. For example, // the "MOD" priority level is assigned to the "borks" role in the -// Okami speedrunning discord. These assignments are stored via keyv in the -// privilegedRoles namespace of database.sqlite. -// const { REST } = require('@discordjs/rest'); +// Okami speedrunning discord. These assignments are stored in the corresponding +// guild-config directory in privilegedRoles.json import { REST } from '@discordjs/rest'; import { Routes } from 'discord-api-types/v9'; @@ -24,18 +23,14 @@ import { importJSON } from './importJSON.mjs'; const { clientId, token, masterUserId } = await importJSON(pkgRelPath('./config.json')); import * as privilegeLevels from '../privilegeLevels.mjs'; -import Keyv from 'keyv'; - -// Load configuration database. This will be used to find which privilege -// privilege levels are associated with which roles in this guild -const privilegedRolesDB = new Keyv( - 'sqlite://database.sqlite', - { namespace: 'privilegedRoles' } -); -privilegedRolesDB.on('error', err => console.log( - 'Connection Error when searching for privilegedRolesDB', - err -)); +import * as guildConfig from '../util/guildConfig.mjs'; +/** Load privileged roles data from guild-config directory */ +async function getPrivilegedRoles(guildId) { + return guildConfig.get( + guildId, + 'privilegedRoles' + ); +} export async function deployPermissions({ // The id of the guild to which to apply the command permission overwrites: @@ -56,9 +51,6 @@ export async function deployPermissions({ } commandNameToMinPrivs[command.data.name] = command.minimumPrivilege; } - - // const commandNameToMinPrivs = await commandMetadataDB.get('minPrivileges'); - // If there are no commands which have setDefaultPermission(false), don't // bother registering command permission overwrites: if (Object.keys(commandNameToMinPrivs).length === 0) { @@ -72,7 +64,7 @@ export async function deployPermissions({ // May be undefined if privileged roles haven't been configured for this guild // yet: - const privilegedRolesForThisGuild = await privilegedRolesDB.get(guildId); + const privilegedRolesForThisGuild = await getPrivilegedRoles(guildId); for (const commandName in commandNameToMinPrivs) { const currentCommandMinPriv = commandNameToMinPrivs[commandName]; diff --git a/util/fetchStatusCode.mjs b/util/fetchStatusCode.mjs index 29f1927..dfa0106 100644 --- a/util/fetchStatusCode.mjs +++ b/util/fetchStatusCode.mjs @@ -1,19 +1,64 @@ -import { get } from 'https'; +import { get as getHttp } from 'node:http'; +import { get as getHttps } from 'node:https'; +const protocolToGet = { + 'http:': getHttp, + 'https:': getHttps, +}; -// For the given URL, make an https request. If a response is received, -// resolve with the 3-digit HTTP response status code. E.G. 404. If a -// response isn't received, reject with error. -export async function fetchStatusCode(url) { - return new Promise((resolve, reject) => { - const request = get(url, response => { +function getHttpOrHttps(url, callback) { + const protocol = (new URL(url)).protocol; + const get = protocolToGet[protocol]; + if (get === undefined) { + return null; + } + return protocolToGet[protocol](url, callback); +} - resolve(response.statusCode); +export async function fetchResponse(url) { + return new Promise((resolve, reject) => { + const request = getHttpOrHttps(url, response => { + resolve(response); }); + if (request === null) { + reject('Invalid protocol'); + } // The request can error if there is no response from the server, or for // other reasons, such as the protocol not being 'https': request.on('error', httpsError => { - reject(httpsError.code); + reject(httpsError); }); }); +} + +// In the case of a redirect, make another request: +export async function fetchResponseChain(url) { + const responses = []; + try { + while (true) { + const response = await fetchResponse(url); + responses.push({ url, result: response.statusCode }); + if (Math.floor(response.statusCode / 100) !== 3) { + break; + } + url = response.headers.location; + } + } + catch (httpsError) { + responses.push({ url, result: httpsError.code }); + } + return responses; +} + +// For the given URL, make an https request. If a response is received, +// resolve with the 3-digit HTTP response status code. E.G. 404. If a +// response isn't received, reject with error. +export async function fetchStatusCode(url) { + try { + const response = await fetchResponse(url); + return response.statusCode; + } + catch (httpsError) { + throw httpsError.code; + } } \ No newline at end of file diff --git a/util/guildConfig.mjs b/util/guildConfig.mjs index dec2288..e7a8260 100644 --- a/util/guildConfig.mjs +++ b/util/guildConfig.mjs @@ -58,6 +58,12 @@ function getConfigDirPath(guildId) { * config file is for. * @param {string} namespace An identifier signifying what kind of configuration * data this config file holds. + * @returns {(object|undefined)} Returns the object containing the deserialized + * config data for the requested guildId and namespace, or undefined if no such + * config data has yet been created using the set function of this module. + * @throws {Error} Throws an error if the content of the corresponding file is + * not a valid JSON-parseable string. This should only happen if the file was + * manually edited */ export async function get(guildId, namespace) { const cacheKey = getCacheKey(guildId, namespace); @@ -73,7 +79,6 @@ export async function get(guildId, namespace) { catch (fileReadingError) { // If the file doesn't exist, return undefined. This matches the behavior of // Keyv. - console.warn('error reading config file: ', configFilePath); return undefined; } let parsedObj; diff --git a/util/importDir.mjs b/util/importDir.mjs index bcc9faf..45dab3d 100644 --- a/util/importDir.mjs +++ b/util/importDir.mjs @@ -54,15 +54,4 @@ export async function importDir(dirPath) { }); dirCache.set(dirPath, modules); return modules; - - // Alternate version which returns object mapping from filenames to modules? - // This would give you access to the filenames, which you don't get with the - // above, but I'm not sure if that's necessary or helpful... - // const modules = await Promise.all(importPromises); - // const moduleObj = Object.create(null); - // for (let i = 0, len = modules.length; i < len; ++i) { - // moduleObj[jsFilenames[i]] = modules[i].default; - // } - // dirCache.set(dirPath, moduleObj); - // return moduleObj; } \ No newline at end of file diff --git a/util/manageUrlsDB.mjs b/util/manageMonitoredURLs.mjs similarity index 76% rename from util/manageUrlsDB.mjs rename to util/manageMonitoredURLs.mjs index 8175c15..4157e56 100644 --- a/util/manageUrlsDB.mjs +++ b/util/manageMonitoredURLs.mjs @@ -1,51 +1,49 @@ -import Keyv from 'keyv'; - -// Load URL database. This is used to store URLs contained in the requested -// message. These URLs are periodically checked by the -// monitorURLsForHTTPErrors routine to see if any of them give HTTP errors. -// If any of them do, notify the creator of the message. -const urlsDB = new Keyv( - 'sqlite://database.sqlite', - { namespace: 'guildResources' } -); -urlsDB.on('error', err => console.log( - 'Connection Error when searching for urlsDB', - err -)); +import * as guildConfig from './guildConfig.mjs'; +/** Load monitored URL data from guild-config directory */ +async function getMonitoredURLs(guildId) { + return guildConfig.get( + guildId, + 'monitoredURLs' + ); +} +/** Write monitored URL data to guild-config directory */ +async function setMonitoredURLs(guildId, guildMonitoredURLs) { + return guildConfig.set(guildId, 'monitoredURLs', guildMonitoredURLs); +} // Cache: -const guildIdToUrlObjs = Object.create(null); const guildIdToUrlMaps = Object.create(null); -// Structure of a urlObj: - -// const urlObj = { -// url: 'https://example.com', // Which URL to check -// enabled: true, // Whether this URL should be checked in monitorURLsForHTTPErrors -// notifyChannels: { // Where to send notification messages -// '931013295740166194': { // id of channel to send message to -// userIds: ['263153040041836555', '163125227826446336'], // users to notify -// info: 'My example website to check', // Note for this notification -// }, -// '931013295740166195': { // another channel to send a message to: -// ... -// }, -// }, -// }; - -// Returns an array of all url objects in the DB for the given guild id: +/** + * @typedef {Object} NotifyChannelInfo An object storing information about the + * notification to send to a given channel + * @prop {string[]} userIds + * @prop {string} [info] + * + * @typedef {Object} UrlObj An object storing information about a URL being + * monitored by AutoRide via the monitorURLsForHTTPErrors routine. + * @prop {string} url The URL being monitored + * @prop {boolean} enabled Whether the URL is currently being monitored. If + * false, monitoring is temporarily disabled for the url until re-enabled via + * the http-monitor re-enable command + * @prop {Object.} notifyChannels An object + * mapping from channel Ids to objects containing information about the + * notification message to send to that channel in the event of an error. + */ + +/** + * @param {string} guildId The Id of the guild to get monitored URL data for + * @returns {Promise} An array of all url objects for the given guild: + */ export async function getUrlObjsForGuild(guildId) { - if (guildId in guildIdToUrlObjs) { - return guildIdToUrlObjs[guildId]; - } - const urlObjs = await urlsDB.get(guildId); + /** @type {UrlObj[]} */ + const urlObjs = await getMonitoredURLs(guildId); // If this guild doesn't have a urlObjs array, make an empty one: if (!urlObjs) { const emptyUrlObj = []; - await urlsDB.set(guildId, emptyUrlObj); + await setMonitoredURLs(guildId, emptyUrlObj); return emptyUrlObj; } - guildIdToUrlObjs[guildId] = urlObjs; return urlObjs; } @@ -61,10 +59,9 @@ export async function getUrlObjByUrl(guildId, url) { if (guildId in guildIdToUrlMaps) { return guildIdToUrlMaps[guildId].get(url); } - const urlObjs = await getUrlObjsForGuild(guildId); + const urlObjs = await getMonitoredURLs(guildId); const urlMap = new Map(); for (const urlObj of urlObjs) { - urlMap.set(3, 4); urlMap.set(urlObj.url, urlObj); } guildIdToUrlMaps[guildId] = urlMap; @@ -118,10 +115,9 @@ export function mergeUrlObj({ target, source } = {}) { // Adds a new urlObj for this guild, or merges the parameter obj with the // existing obj, updating the info and adding any new channels/users: // async function addUrlObj(guildId, urlObj) { -export async function addUrlObjs(guildId, urlObjs) { - console.log('updating urlObjs 1'); - - for (const urlObjToAdd of urlObjs) { +export async function addUrlObjs(guildId, urlObjsToAdd) { + const urlObjs = await getUrlObjsForGuild(guildId); + for (const urlObjToAdd of urlObjsToAdd) { const urlToAdd = urlObjToAdd.url; const currentUrlObj = await getUrlObjByUrl(guildId, urlToAdd); @@ -132,7 +128,6 @@ export async function addUrlObjs(guildId, urlObjs) { guildIdToUrlMaps[guildId].set(urlToAdd, urlObjToAdd); urlObjs.push(urlObjToAdd); - urlsDB.set(guildId, urlObjs); continue; } // If the requested URL is ALREADY being monitored, add the data from @@ -140,10 +135,7 @@ export async function addUrlObjs(guildId, urlObjs) { // url only gets one urlObj per guild. mergeUrlObj({ target: currentUrlObj, source: urlObjToAdd }); } - - console.log('updating urlObjs 2'); - console.log(JSON.stringify(urlObjs, null, 2)); - return urlsDB.set(guildId, urlObjs); + return setMonitoredURLs(guildId, urlObjs); } // Set the url object for the given url in the given guild to be either enabled @@ -159,7 +151,7 @@ export async function setUrlEnabled({ } const urlObjs = await getUrlObjsForGuild(guildId); urlObj.enabled = enabled; - await urlsDB.set(guildId, urlObjs); + await setMonitoredURLs(guildId, urlObjs); return urlObj; } @@ -180,7 +172,7 @@ export async function setUrlsEnabled({ for (const selectedUrlObj of selectedUrlObjs) { selectedUrlObj.enabled = enabled; } - await urlsDB.set(guildId, urlObjs); + await setMonitoredURLs(guildId, urlObjs); return selectedUrlObjs; } @@ -197,7 +189,7 @@ export async function deleteUrlObj(guildId, url) { return false; } urlObjs.splice(index, 1); - await urlsDB.set(guildId, urlObjs); + await setMonitoredURLs(guildId, urlObjs); // Remove the given url from the cache map: if (guildId in guildIdToUrlMaps) { @@ -220,7 +212,7 @@ export async function overwriteUrlObj(guildId, newUrlObj) { urlObjs.push(newUrlObj); } urlObjs.splice(index, 1, newUrlObj); - await urlsDB.set(guildId, urlObjs); + await setMonitoredURLs(guildId, urlObjs); guildIdToUrlMaps[guildId].set(overwritingUrl, newUrlObj); } \ No newline at end of file diff --git a/util/splitMessageRegex.mjs b/util/splitMessageRegex.mjs new file mode 100644 index 0000000..c4d2213 --- /dev/null +++ b/util/splitMessageRegex.mjs @@ -0,0 +1,66 @@ +/** + * A function for splitting a string into fixed-length parts. Designed as a + * workaround to an issue in the discord.js Util.splitMessage function + * https://github.com/discordjs/discord.js/issues/7674 + * @param {string} text The string to split into multiple messages, each of + * which will be no longer than maxLength + * @param {object} [options] + * @param {number} [options.maxLength] The maximum number of characters in each + * string in the returned array + * @param {RegExp} [options.regex] A global regex which matches the delimeters on + * which to split text when needed to keep each part within maxLength + * @param {string} [options.prepend] A string to add before each part iff + * text is split into multiple parts + * @param {string} [options.append] A string to add after each part iff text + * is split into multiple parts + * @returns {string[]} An array of strings which are substrings of text, split + * using options.regex, combined such that each part is as long as possible + * while not exceeding options.maxLength. + */ +export function splitMessageRegex(text, { + maxLength = 2_000, + regex = /\n/g, + prepend = '', + append = '', +} = {}) { + if (text.length <= maxLength) return [text]; + const parts = []; + let curPart = prepend; + let chunkStartIndex = 0; + + let prevDelim = ''; + + function addChunk(chunkEndIndex, nextDelim) { + const nextChunk = text.substring(chunkStartIndex, chunkEndIndex); + const nextChunkLen = nextChunk.length; + + // If a single part would exceed the length limit by itself, throw an error: + if (prepend.length + nextChunkLen + append.length > maxLength) { + throw new RangeError('SPLIT_MAX_LEN'); + } + + // The length of the current part if the next chunk were added to it: + const lengthWithChunk = ( + curPart.length + prevDelim.length + nextChunkLen + append.length + ); + + // If adding the next chunk to the current part would cause it to exceed + // the maximum length, push the current part and reset it for next time: + if (lengthWithChunk > maxLength) { + parts.push(curPart + append); + curPart = prepend + nextChunk; + } + else { + curPart += prevDelim + nextChunk; + } + prevDelim = nextDelim; + chunkStartIndex = chunkEndIndex + prevDelim.length; + } + + for (const match of text.matchAll(regex)) { + addChunk(match.index, match[0]); + } + addChunk(text.length - 1, ''); + parts.push(curPart + append); + return parts; +} \ No newline at end of file