Skip to content

Commit

Permalink
Switch to guildConfig and finish url monitoring
Browse files Browse the repository at this point in the history
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: discordjs/discord.js#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.
  • Loading branch information
dshepsis committed Mar 24, 2022
1 parent 4db385b commit 4164f11
Show file tree
Hide file tree
Showing 22 changed files with 648 additions and 229 deletions.
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
9 changes: 0 additions & 9 deletions commands/colors.mjs
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
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,
} = createRoleSelector({
name: 'colors',
description: 'Select your username color.',
async rolesFromInteraction(interaction) {
// return colorRolesDB.get(interaction.guildId);
return guildConfig.get(interaction.guildId, 'colorRoles');
},
sortByGuildOrder: true,
Expand Down
44 changes: 44 additions & 0 deletions commands/command-util/Replyable.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
35 changes: 24 additions & 11 deletions commands/command-util/awaitCommandConfirmation.mjs
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -8,23 +9,33 @@ 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
// user alongside the confirm/cancel buttons:
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
// upon pressing the confrim button.
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
Expand Down Expand Up @@ -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 => (
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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}'!`);
}
9 changes: 5 additions & 4 deletions commands/command-util/awaitCommandReply.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
} = {}) {
Expand Down Expand Up @@ -57,9 +57,10 @@ export async function awaitCommandReply({
botMessage,
};
}
// Successfully collected a valid user reply:
return {
responseType: USER_REPLY,
userReply: collected.first(),
userReply,
botMessage,
};
}
Expand Down
102 changes: 102 additions & 0 deletions commands/command-util/paginatedReply.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
});
}
3 changes: 0 additions & 3 deletions commands/command-util/roleSelector.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}>!`;
Expand Down
Loading

0 comments on commit 4164f11

Please sign in to comment.