diff --git a/README.md b/README.md index 483b270..4a12e68 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ In your `.env` file, you need to set the following variables: CYPHERBOT_CLIENT_ID= CYPHERBOT_TARGET_GUILD_ID= CYPHERBOT_TOKEN= +MIN_VOTE_COUNT= +HOURS_TO_CLOSE= DEFAULT_QUEUEMSG_CHANNELID= DB_NAME= DB_CONN_STRING=mongodb+srv://:@/?retryWrites=true&w=majority diff --git a/src/client.ts b/src/client.ts index 3194cda..f08140e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,6 +6,7 @@ import cmd_register from "./commands/register"; import { cmd_tenmans, handleButton as tenmansHandleButton, + handleVoteCleaning, } from "./commands/tenmans"; import { cmdConfig } from "./commands/config"; import botConfig from "./config/botConfig"; @@ -20,15 +21,24 @@ async function initBotConfig(client: Client, db: Db) { // Persist default configuration data const defaultChannelId = process.env.DEFAULT_QUEUEMSG_CHANNELID; + const minVoteCount = process.env.MIN_VOTE_COUNT; + const hoursTillVoteClose = process.env.HOURS_TO_CLOSE; botConfigDoc = await botConfigCollection.insertOne({ configName: "bot", + minVoteCount: minVoteCount, + hoursTillVoteClose: hoursTillVoteClose, queueChannelId: defaultChannelId, }); } const channelId = botConfigDoc.queueChannelId; + botConfig.queueMsgChannel = client.channels.cache.get(channelId); + botConfig.minVoteCount = botConfigDoc.minVoteCount; + botConfig.hoursTillVoteClose = botConfigDoc.hoursTillVoteClose; + + setInterval(handleVoteCleaning, 300000); // Clean vote embeds every 5 minutes } export default (db: Db) => { diff --git a/src/commands/command.ts b/src/commands/command.ts index 6420ab2..2ee0a0b 100644 --- a/src/commands/command.ts +++ b/src/commands/command.ts @@ -28,7 +28,7 @@ export abstract class RegisteredUserExecutable< if (!user) { this.interaction.reply({ content: - "Who are you? Copy of me?! You need to register with me before joining 10mans! Please visit #rules for more info.", + "Who are you? You need to register with me before participating in 10mans! Please visit #rules for more info.", ephemeral: true, }); diff --git a/src/commands/tenmans.ts b/src/commands/tenmans.ts index 799f266..c0f70ba 100644 --- a/src/commands/tenmans.ts +++ b/src/commands/tenmans.ts @@ -8,7 +8,8 @@ import { Constants, ButtonInteraction, Interaction, - GuildApplicationCommandManager, + Role, + Collection, } from "discord.js"; import { Db } from "mongodb"; import botConfig from "../config/botConfig"; @@ -23,45 +24,102 @@ import { let tenmansQueue: Member[] = []; let time: String | null; let activeTenmansMessage: Message | null; +let activeVoteMessage: Message | null; +let voteClosingTime: Date | null; -abstract class QueueAction< +abstract class BaseQueueAction< T extends RepliableInteraction > extends RegisteredUserExecutable { constructor(interaction: T, db: Db, protected queueId: string) { super(interaction, db); } + /// Verify that the queue id stored in the constructor is correct. + /// If not, allow child class to throw custom error message and + /// fail gracefully. + abstract verifyQueueId(): string; - abstract updateQueue(); + /// Perform actions after verifying queue id. Should not modify any + /// messages related to queue state. + abstract updateQueue(): Promise; + + /// Perform actions after updating queue state. This includes things + /// like updating related embed messages, etc. + abstract updateUserInterface(); async afterUserExecute(): Promise { - if (activeTenmansMessage === null) { + const errorMessage = this.verifyQueueId(); + if (errorMessage) { this.interaction.reply({ - content: "I must wait a moment! There is no active 10mans queue.", + content: errorMessage, ephemeral: true, }); + return; } - await this.updateQueue(); + const shouldRender = await this.updateQueue(); + + if (shouldRender) { + this.updateUserInterface(); + } + } +} +abstract class StandardQueueAction< + T extends RepliableInteraction +> extends BaseQueueAction { + verifyQueueId() { + if (activeTenmansMessage === null) { + return "I must wait a moment! There is no active 10mans queue."; + } + } + + updateUserInterface() { activeTenmansMessage.edit({ embeds: [createEmbed(time)], }); } } -class JoinQueueButtonAction extends QueueAction { - updateQueue() { +abstract class VoteQueueAction< + T extends RepliableInteraction +> extends BaseQueueAction { + verifyQueueId() { + return ""; + } + + updateUserInterface() { + const votesStillNeeded = botConfig.minVoteCount - tenmansQueue.length; + + activeVoteMessage.edit({ + embeds: [createVoteEmbed(votesStillNeeded, time, voteClosingTime)], + }); + } +} + +class JoinQueueButtonAction extends StandardQueueAction { + async updateQueue(): Promise { + if (tenmansQueue.some(queueUser => this.user.discordId === queueUser.discordId)) { + this.interaction.reply({ + content: "Who are you? Copy of me?! You're already in the queue!", + ephemeral: true, + }); + + return false; + } + tenmansQueue.push(this.user); this.interaction.reply({ content: "Greetings! You've been added to the queue.", ephemeral: true, }); + + return true; } } -class LeaveQueueButtonAction extends QueueAction { - updateQueue() { +class LeaveQueueButtonAction extends StandardQueueAction { + async updateQueue(): Promise { tenmansQueue = tenmansQueue.filter( (member) => member.discordId !== this.user.discordId ); @@ -69,11 +127,77 @@ class LeaveQueueButtonAction extends QueueAction { content: "This is no problem; You've been removed from the queue.", ephemeral: true, }); + + return true; } } -class ManualAddUserToQueue extends QueueAction { - async updateQueue() { +class VoteQueueButtonAction extends VoteQueueAction { + async updateQueue(): Promise { + const queueChannel = botConfig.queueMsgChannel as TextChannel; + + // Verify that a pingable role exists for 10 mans on this server + const roleId = await this.interaction.guild.roles + .fetch() + .then((roles: Collection) => { + for (const role of roles.values()) { + if (role.name === "10 Mans") { + return role.id; + } + } + }) + .catch(console.error); + + if (!roleId) { + this.interaction.reply({ + content: + "My camera is destroyed - cannot find a 10 mans role on this server. Message an admin.", + ephemeral: true, + }); + + return false; + } + + if (tenmansQueue.some(queueUser => this.user.discordId === queueUser.discordId)) { + this.interaction.reply({ + content: "Who are you? Copy of me?! You've already voted!", + ephemeral: true, + }); + + return false; + } + + tenmansQueue.push(this.user); + + this.interaction.reply({ + content: "Greetings! Your vote has been counted.", + ephemeral: true, + }); + + if (tenmansQueue.length >= botConfig.minVoteCount) { + // Generate proper interactable queue once min votes reached + await activeVoteMessage.delete(); + voteClosingTime = null; + activeVoteMessage = null; + + activeTenmansMessage = await queueChannel.send({ + embeds: [createEmbed(time)], + components: [createQueueActionRow(this.queueId)], + }); + + await queueChannel.send({ + content: `<@${roleId}> that Radianite must be ours! A queue has been created!`, + }); + + return false; + } + + return true; + } +} + +class ManualAddUserToQueue extends StandardQueueAction { + async updateQueue(): Promise { const targetUser = this.interaction.options.getUser("member"); const targetMember = (await this.db.collection("members").findOne({ discordId: targetUser.id, @@ -83,11 +207,13 @@ class ManualAddUserToQueue extends QueueAction { content: `User \`${targetUser.username}\` added to the queue.`, ephemeral: true, }); + + return true; } } -class ManualRemoveUserToQueue extends QueueAction { - async updateQueue() { +class ManualRemoveUserToQueue extends StandardQueueAction { + async updateQueue(): Promise { const targetUser = this.interaction.options.getUser("member"); const targetMember = (await this.db.collection("members").findOne({ discordId: targetUser.id, @@ -99,6 +225,8 @@ class ManualRemoveUserToQueue extends QueueAction { content: `User \`${targetUser.username}\` removed from the queue.`, ephemeral: true, }); + + return true; } } @@ -164,7 +292,74 @@ class TenmansCloseSubcommand extends MessageExecutable { // Teardown - clear current queue tenmansQueue = []; - await activeTenmansMessage.delete(); + await activeTenmansMessage?.delete(); + } +} + +class TenmansVoteSubcommand extends RegisteredUserExecutable { + async afterUserExecute() { + // Verify queue not already active + if (activeTenmansMessage) { + this.interaction.reply({ + content: + "You should have been looking more closely. There's already a queue - use that one instead!", + ephemeral: true, + }); + + return; + } + + // Verify bot config is valid + const requiredConfigs = [ + "hoursTillVoteClose", + "minVoteCount", + "queueMsgChannel", + ]; + for (const setting of requiredConfigs) { + if (!botConfig[setting]) { + this.interaction.reply({ + content: `Careful now. \`${setting}\` not configured. Please ask an admin to configure this value.`, + ephemeral: true, + }); + + return; + } + } + + if (!tenmansQueue?.length) { + // init queue if it doesn't exist + voteClosingTime = new Date(); + voteClosingTime.setHours( + voteClosingTime.getHours() + botConfig.hoursTillVoteClose + ); + time = this.interaction.options.getString("time"); + + tenmansQueue = []; + tenmansQueue.push(this.user); + + const queueChannel = botConfig.queueMsgChannel as TextChannel; + const queueId = "stub"; + + const votesStillNeeded = botConfig.minVoteCount - tenmansQueue.length; + activeVoteMessage = await queueChannel.send({ + embeds: [ + createVoteEmbed(votesStillNeeded, time, voteClosingTime), + ], + components: [createVoteQueueActionRow(queueId)], + }); + + this.interaction.reply({ + content: "Vote started!", + ephemeral: true, + }); + } else { + this.interaction.reply({ + content: "Cage triggered. A vote is already active, check it out!", + ephemeral: true, + }); + + return; + } } } @@ -180,6 +375,7 @@ export async function cmd_tenmans(interaction: CommandInteraction, db: Db) { } = { start: SubcommandTenmansStart, end: TenmansCloseSubcommand, + vote: TenmansVoteSubcommand, add_user: ManualAddUserToQueue, remove_user: ManualRemoveUserToQueue, }; @@ -194,7 +390,11 @@ export async function cmd_tenmans(interaction: CommandInteraction, db: Db) { }); return; } - const command = new Action(interaction, db, interaction.options.getString("queueId")); + const command = new Action( + interaction, + db, + interaction.options.getString("queueId") + ); command.execute(); } @@ -205,11 +405,12 @@ export async function handleButton(interaction: ButtonInteraction, db: Db) { interaction: ButtonInteraction, db: Db, queueId: string - ): QueueAction; + ): BaseQueueAction; }; } = { join: JoinQueueButtonAction, leave: LeaveQueueButtonAction, + vote: VoteQueueButtonAction, }; const actionParts = interaction.customId.split("."); const [commandName, queueId] = actionParts[actionParts.length - 1].split(":"); @@ -226,6 +427,7 @@ export async function handleButton(interaction: ButtonInteraction, db: Db) { command.execute(); } +// TODO: Move embed generators into their own directory/module const createEmbed = (time) => new MessageEmbed() .setColor("#0099ff") @@ -247,6 +449,25 @@ const createEmbed = (time) => .setTimestamp() .setFooter("Last Updated"); +const createVoteEmbed = (votesStillNeeded: number, time, closingTime: Date) => + new MessageEmbed() + .setColor("#0099ff") + .setTitle(`Ten Mans Vote For ${time}`) + .addField( + "Votes needed to start queue: ", + votesStillNeeded.toString(), + true + ) + .addField( + "Discord Member", + tenmansQueue.length > 0 + ? tenmansQueue.map((member) => `<@${member.discordId}>`).join("\n") + : "No Players", + true + ) + .setTimestamp() + .setFooter(`Vote ends at ${closingTime.toLocaleString()}`); + const createQueueActionRow = (queueId) => { return new MessageActionRow().addComponents( new MessageButton() @@ -259,3 +480,24 @@ const createQueueActionRow = (queueId) => { .setStyle(Constants.MessageButtonStyles.DANGER) ); }; + +const createVoteQueueActionRow = (queueId) => { + return new MessageActionRow().addComponents( + new MessageButton() + .setCustomId(`tenmans.vote:${queueId}`) + .setLabel("Vote") + .setStyle(Constants.MessageButtonStyles.SUCCESS) + ); +}; + +export async function handleVoteCleaning() { + if (activeVoteMessage) { + // Close vote if it has expired + if (voteClosingTime < new Date()) { + await activeVoteMessage.delete(); + + tenmansQueue = []; + voteClosingTime = null; + } + } +} diff --git a/src/config/botConfig.ts b/src/config/botConfig.ts index e78ae23..88f9378 100644 --- a/src/config/botConfig.ts +++ b/src/config/botConfig.ts @@ -1,7 +1,11 @@ import { Channel } from "discord.js"; class BotConfig { - constructor(public queueMsgChannel?: Channel) {} + constructor( + public queueMsgChannel?: Channel, + public minVoteCount?: number, + public hoursTillVoteClose?: number, + ) {} } export default new BotConfig();