diff --git a/src/attending-server/base-attending-server.ts b/src/attending-server/base-attending-server.ts index 749fa5be..1fb850bb 100644 --- a/src/attending-server/base-attending-server.ts +++ b/src/attending-server/base-attending-server.ts @@ -87,6 +87,11 @@ type ServerSettings = { * - Variables with an underscore has a public getter, but only mutable inside the class */ class AttendingServerV2 { + /** + * Unique helpers (both active and paused) + * - Key is GuildMember.id + */ + private _helpers: Collection = new Collection(); /** * All the queues of this server * - Key is CategoryChannel.id of the parent category of #queue @@ -96,11 +101,6 @@ class AttendingServerV2 { * Cached result of {@link getQueueChannels} */ private queueChannelsCache: QueueChannel[] = []; - /** - * Unique helpers (both active and paused) - * - Key is GuildMember.id - */ - private _helpers: Collection = new Collection(); /** * Server settings. An firebase update is requested as soon as this changes */ @@ -113,61 +113,59 @@ class AttendingServerV2 { staff: SpecialRoleValues.NotSet, student: SpecialRoleValues.NotSet } - }; // TODO: Use the Proxy class to abstract away the update logic + }; + // TODO: Use the Proxy class to abstract away the update logic protected constructor( readonly guild: Guild, readonly serverExtensions: ReadonlyArray ) {} - /** List of queues on this server */ - get queues(): ReadonlyArray { - return [...this._queues.values()]; - } - /** All the students that are currently in a queue */ - get studentsInAllQueues(): ReadonlyArray { - return this.queues.flatMap(queue => queue.students); - } - /** All the helpers on this server, both active and paused */ - get helpers(): ReadonlyMap { - return this._helpers; - } - /** Auto clear values of a queue, undefined if not set */ - get queueAutoClearTimeout(): Optional { - return this._queues.first()?.timeUntilAutoClear; - } /** The after session message string. Empty string if not set */ get afterSessionMessage(): string { return this.settings.afterSessionMessage; } - /** The logging channel on this server. undefined if not set */ - get loggingChannel(): Optional { - return this.settings.loggingChannel; + + /** whether to automatically give new members the student role */ + get autoGiveStudentRole(): boolean { + return this.settings.autoGiveStudentRole; } + /** bot admin role id */ get botAdminRoleID(): OptionalRoleId { return this.settings.hierarchyRoleIds.botAdmin; } - /** staff role id */ - get staffRoleID(): OptionalRoleId { - return this.settings.hierarchyRoleIds.staff; - } - /** student role id, this is the everyone role if "@everyone is student" is selected */ - get studentRoleID(): OptionalRoleId { - return this.settings.hierarchyRoleIds.student; + + /** All the helpers on this server, both active and paused */ + get helpers(): ReadonlyMap { + return this._helpers; } + /** All the hierarchy role ids */ get hierarchyRoleIds(): HierarchyRoles { return this.settings.hierarchyRoleIds; } - /** whether to automatically give new members the student role */ - get autoGiveStudentRole(): boolean { - return this.settings.autoGiveStudentRole; + + /** The logging channel on this server. undefined if not set */ + get loggingChannel(): Optional { + return this.settings.loggingChannel; } + /** whether to prompt modal asking for help topic when a user joins a queue */ get promptHelpTopic(): boolean { return this.settings.promptHelpTopic; } + + /** Auto clear values of a queue, undefined if not set */ + get queueAutoClearTimeout(): Optional { + return this._queues.first()?.timeUntilAutoClear; + } + + /** List of queues on this server */ + get queues(): ReadonlyArray { + return [...this._queues.values()]; + } + /** * Returns an array of the roles for this server in the order [Bot Admin, Helper, Student] */ @@ -183,115 +181,19 @@ class AttendingServerV2 { })); } - /** - * Cleans up all the timers from setInterval - */ - clearAllServerTimers(): void { - this._queues.forEach(queue => queue.clearAllQueueTimers()); - } - - /** - * Sends the log message to the logging channel if it's set up - * @param message message to log - */ - sendLogMessage(message: BaseMessageOptions | string): void { - if (this.loggingChannel) { - this.loggingChannel.send(message).catch(e => { - console.error(red(`Failed to send logs to ${this.guild.name}.`)); - console.error(e); - }); - } - } - - /** - * Gets a queue channel by the parent category id - * @param parentCategoryId the associated parent category id - * @returns queue channel object if it exists, undefined otherwise - */ - getQueueChannelById(parentCategoryId: Snowflake): Optional { - return this._queues.get(parentCategoryId)?.queueChannel; - } - - /** - * Loads the server settings data from a backup - * - queue backups are passed to the queue constructors - * @param backup the data to load - */ - private loadBackup(backup: ServerBackup): void { - console.log(cyan(`Found external backup for ${this.guild.name}. Restoring.`)); - this.settings = { - ...backup, - hierarchyRoleIds: { - botAdmin: backup.botAdminRoleId, - staff: backup.staffRoleId, - student: backup.studentRoleId - } - }; - const loggingChannelFromBackup = this.guild.channels.cache.get( - backup.loggingChannelId - ); - if (isTextChannel(loggingChannelFromBackup)) { - this.settings.loggingChannel = loggingChannelFromBackup; - } - } - - /** - * Sets the internal boolean value for autoGiveStudentRole - * @param autoGiveStudentRole on or off - */ - async setAutoGiveStudentRole(autoGiveStudentRole: boolean): Promise { - this.settings.autoGiveStudentRole = autoGiveStudentRole; - await Promise.all( - this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) - ); - } - - /** - * Sets the internal boolean value for promptHelpTopic - * @param promptHelpTopic - */ - async setPromptHelpTopic(promptHelpTopic: boolean): Promise { - this.settings.promptHelpTopic = promptHelpTopic; - await Promise.all( - this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) - ); + /** staff role id */ + get staffRoleID(): OptionalRoleId { + return this.settings.hierarchyRoleIds.staff; } - /** - * Sets the hierarchy roles to use for this server - * @param role name of the role; botAdmin, staff, or student - * @param id the role id snowflake - */ - async setHierarchyRoleId(role: keyof HierarchyRoles, id: Snowflake): Promise { - this.settings.hierarchyRoleIds[role] = id; - await Promise.all([ - updateCommandHelpChannelVisibility( - this.guild, - this.settings.hierarchyRoleIds - ), - ...this.serverExtensions.map(extension => - extension.onServerRequestBackup(this) - ) - ]); + /** student role id, this is the everyone role if "@everyone is student" is selected */ + get studentRoleID(): OptionalRoleId { + return this.settings.hierarchyRoleIds.student; } - /** - * Sets the serious server flag, and updates the queues if seriousness is changed - * @param enableSeriousMode turn on or off serious mode - * @returns True if triggered renders for all queues - */ - async setSeriousServer(enableSeriousMode: boolean): Promise { - const seriousState = this.queues[0]?.seriousModeEnabled ?? false; - if (seriousState === enableSeriousMode) { - return false; - } - await Promise.all([ - ...this._queues.map(queue => queue.setSeriousMode(enableSeriousMode)), - ...this.serverExtensions.map(extension => - extension.onServerRequestBackup(this) - ) - ]); - return true; + /** All the students that are currently in a queue */ + get studentsInAllQueues(): ReadonlyArray { + return this.queues.flatMap(queue => queue.students); } /** @@ -354,137 +256,189 @@ class AttendingServerV2 { } /** - * Called when a member joins a voice channel - * - triggers onStudentJoinVC for all extensions if the member is a - * student and was just removed from the queue - * @param member the guild member that just joined a VC - * @param newVoiceState voice state object with a guaranteed non-null channel + * Adds a student to the notification group + * @param studentMember student to add + * @param targetQueue which notification group to add to */ - async onMemberJoinVC( - member: GuildMember, - newVoiceState: WithRequired + async addStudentToNotifGroup( + studentMember: GuildMember, + targetQueue: QueueChannel ): Promise { - // temporary solution, stage channel is not currently supported - if (!isVoiceChannel(newVoiceState.channel)) { - return; - } - const vc = newVoiceState.channel; - const memberIsStudent = this._helpers.some(helper => - helper.helpedMembers.some( - helpedMember => helpedMember.member.id === member.id - ) - ); - const memberIsHelper = this._helpers.has(member.id); - if (memberIsStudent) { - const possibleHelpers = newVoiceState.channel.members.filter( - vcMember => vcMember.id !== member.id + await this._queues + .get(targetQueue.parentCategoryId) + ?.addToNotifGroup(studentMember); + } + + /** + * Send an announcement to all the students in the helper's approved queues + * @param helperMember helper that used /announce + * @param announcement announcement message + * @param targetQueue optional, specifies which queue to announce to + */ + async announceToStudentsInQueue( + helperMember: GuildMember, + announcement: string, + targetQueue?: QueueChannel + ): Promise { + if (targetQueue !== undefined) { + const queueToAnnounce = this._queues.get(targetQueue.parentCategoryId); + const hasQueueRole = helperMember.roles.cache.some( + role => + role.name === targetQueue.queueName || role.id === this.botAdminRoleID ); - const queuesToRerender = this._queues.filter(queue => - possibleHelpers.some(possibleHelper => queue.hasHelper(possibleHelper.id)) + if (!queueToAnnounce || !hasQueueRole) { + throw ExpectedServerErrors.noAnnouncePerm(targetQueue.queueName); + } + const announcementEmbed = SimpleEmbed( + `Staff member ${helperMember.displayName} announced:\n${announcement}`, + EmbedColor.Aqua, + `In queue: ${targetQueue.queueName}` ); - await Promise.all([ - ...this.serverExtensions.map(extension => - extension.onStudentJoinVC(this, member, vc) - ), - ...queuesToRerender.map(queue => queue.triggerRender()) - ]); - } - if (memberIsHelper) { await Promise.all( - this.queues.map( - queue => queue.hasHelper(member.id) && queue.triggerRender() + queueToAnnounce.students.map(student => + student.member.send(announcementEmbed) ) ); + return; } - } - - /** - * Called when a member leaves a voice channel - * - triggers onStudentLeaveVC for all extensions if the member is a - * student and was in a session - * @param member the guild member that just joined a VC - * @param oldVoiceState voice state object with a guaranteed non-null channel - */ - async onMemberLeaveVC( - member: GuildMember, - oldVoiceState: WithRequired - ): Promise { - const memberIsStudent = this._helpers.some(helper => - helper.helpedMembers.some( - helpedMember => helpedMember.member.id === member.id + // from this.queues select queue where helper roles has queue name + const studentsToAnnounceTo = this.queues + .filter(queue => + helperMember.roles.cache.some(role => role.name === queue.queueName) ) - ); - const memberIsHelper = this._helpers.has(member.id); - if (memberIsStudent) { - const possibleHelpers = oldVoiceState.channel.members.filter( - vcMember => vcMember.id !== member.id - ); // treat everyone that's not this student as a potential helper - const queuesToRerender = this.queues.filter(queue => - possibleHelpers.some(possibleHelper => queue.hasHelper(possibleHelper.id)) - ); - await Promise.all([ - ...oldVoiceState.channel.permissionOverwrites.cache.map( - overwrite => overwrite.id === member.id && overwrite.delete() - ), // delete the student permission overwrite - ...this.serverExtensions.map(extension => - extension.onStudentLeaveVC(this, member) - ), - this.afterSessionMessage !== '' && - member.send(SimpleEmbed(this.afterSessionMessage)), - ...queuesToRerender.map(queue => queue.triggerRender()) - ]); - } - if (memberIsHelper) { - // the filter is removed because - // the overwrite will die in 15 minutes after the invite was sent - await Promise.all( - this.queues.map( - queue => queue.hasHelper(member.id) && queue.triggerRender() - ) - ); + .flatMap(queueToAnnounce => queueToAnnounce.students); + if (studentsToAnnounceTo.length === 0) { + throw ExpectedServerErrors.noStudentToAnnounce(announcement); } + const announcementEmbed = SimpleEmbed( + `Staff member ${helperMember.displayName} announced:`, + EmbedColor.Aqua, + announcement + ); + await Promise.all( + studentsToAnnounceTo.map(student => student.member.send(announcementEmbed)) + ); } /** - * Gets all the queue channels on the server. SLOW - * if nothing is found, returns empty array - * @param useCache whether to read from existing cache, defaults to true - * - unless queues change often, prefer cache for fast response + * Cleans up the given queue and resend all embeds + * @param targetQueue the queue to clean */ - async getQueueChannels(useCache = true): Promise { - if (useCache && this.queueChannelsCache.length !== 0) { - return this.queueChannelsCache; + async cleanUpQueue(targetQueue: QueueChannel): Promise { + await this._queues.get(targetQueue.parentCategoryId)?.triggerForceRender(); + } + + /** + * Clear all queues of this server + * @remark separated from clear queue to avoid excessive backup calls + */ + async clearAllQueues(): Promise { + await Promise.all(this._queues.map(queue => queue.removeAllStudents())); + await Promise.all( + this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) + ); + } + + /** + * Cleans up all the timers from setInterval + */ + clearAllServerTimers(): void { + this._queues.forEach(queue => queue.clearAllQueueTimers()); + } + + /** + * Clears the given queue + * @param targetQueue queue to clear + */ + async clearQueue(targetQueue: QueueChannel): Promise { + await this._queues.get(targetQueue.parentCategoryId)?.removeAllStudents(); + await Promise.all( + this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) + ); + } + + /** + * Closes all the queue that the helper has permission to & logs the help time to console + * @param helperMember helper that used /stop + * @throws ServerError if the helper is not hosting + */ + async closeAllClosableQueues(helperMember: GuildMember): Promise> { + const helper = this._helpers.get(helperMember.id); + if (helper === undefined) { + throw ExpectedServerErrors.notHosting; } - const allChannels = await this.guild.channels.fetch(); - // cache again on a fresh request, likely triggers GC - this.queueChannelsCache = []; - for (const categoryChannel of allChannels.values()) { - if (!isCategoryChannel(categoryChannel)) { + this._helpers.delete(helperMember.id); + const completeHelper: Required = { + ...helper, + helpEnd: new Date() + }; + console.log( + ` - Help time of ${helper.member.displayName} is ` + + `${convertMsToTime( + completeHelper.helpEnd.getTime() - completeHelper.helpStart.getTime() + )}` + ); + // this filter does not rely on user roles anymore + // close all queues that has this user as a helper + const closableQueues = this._queues.filter(queue => + queue.hasHelper(helperMember.id) + ); + await Promise.all(closableQueues.map(queue => queue.closeQueue(helperMember))); + await Promise.all( + this.serverExtensions.map(extension => + extension.onHelperStopHelping(this, completeHelper) + ) + ); + return completeHelper; + } + + /** + * Creates all the command access hierarchy roles + * @param allowDuplicate if true, creates new roles even if they already exist + * - Duplicates will be created if roles with the same name already exist + * @param everyoneIsStudent whether to treat @ everyone as the student role + */ + async createHierarchyRoles( + allowDuplicate: boolean, + everyoneIsStudent: boolean + ): Promise { + const allRoles = await this.guild.roles.fetch(); + const everyoneRoleId = this.guild.roles.everyone.id; + const foundRoles = []; // sorted low to high + const createdRoles = []; // not typed bc we are only using it for logging + for (const role of this.sortedHierarchyRoles) { + // do the search if it's NotSet, Deleted, or @everyone + // so if existingRole is not undefined, it's one of @Bot Admin, @Staff or @Student + const existingRole = + (Object.values(SpecialRoleValues) as string[]).includes(role.id) || + role.id === everyoneRoleId + ? allRoles.find(serverRole => serverRole.name === role.displayName) + : allRoles.get(role.id); + if (role.key === 'student' && everyoneIsStudent) { + this.hierarchyRoleIds.student = everyoneRoleId; continue; } - const queueTextChannel: Optional = - categoryChannel.children.cache.find(isQueueTextChannel); - if (!queueTextChannel) { + if (existingRole && !allowDuplicate) { + this.hierarchyRoleIds[role.key] = existingRole.id; + foundRoles[existingRole.position] = existingRole.name; continue; } - this.queueChannelsCache.push({ - channelObj: queueTextChannel, - queueName: categoryChannel.name, - parentCategoryId: categoryChannel.id + const newRole = await this.guild.roles.create({ + ...hierarchyRoleConfigs[role.key], + name: hierarchyRoleConfigs[role.key].displayName }); + this.hierarchyRoleIds[role.key] = newRole.id; + // set by indices that are larger than arr length is valid in JS + // ! do NOT do this with important arrays bc there will be 'empty items' + createdRoles[newRole.position] = newRole.name; } - const duplicateQueues = this.queueChannelsCache - .map(queue => queue.queueName) - .filter((item, index, arr) => arr.indexOf(item) !== index); - if (duplicateQueues.length > 0) { - console.warn(`Server['${this.guild.name}'] contains these duplicate queues:`); - console.warn(duplicateQueues); - console.warn( - `This might lead to unexpected behaviors.\n - Please update category names as soon as possible.` - ); - } - return this.queueChannelsCache; + await Promise.all( + this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) + ); + createdRoles.length > 0 + ? console.log(blue('Created roles:'), createdRoles) + : console.log(green(`All required roles exist in ${this.guild.name}!`)); + foundRoles.length > 0 && console.log('Found roles:', foundRoles); } /** @@ -558,38 +512,6 @@ class AttendingServerV2 { await this.getQueueChannels(false); } - /** - * Attempt to enqueue a student - * @param studentMember student member to enqueue - * @param queueChannel target queue - * @throws QueueError if queue refuses to enqueue - */ - async enqueueStudent( - studentMember: GuildMember, - queueChannel: QueueChannel - ): Promise { - await this._queues.get(queueChannel.parentCategoryId)?.enqueue(studentMember); - await Promise.all( - this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) - ); - } - - /** - * Notify all helpers of the topic that the student requires help with - * @param studentMember the student that just submitted the help topic modal - * @param queueChannel related queue channel - * @param topic the submitted help topic content - */ - async notifyHelpersStudentHelpTopic( - studentMember: GuildMember, - queueChannel: QueueChannel, - topic: string - ): Promise { - await this._queues - .get(queueChannel.parentCategoryId) - ?.notifyHelpersOnStudentSubmitHelpTopic(studentMember, topic); - } - /** * Dequeue the student that has been waiting for the longest globally * @param helperMember the helper that used /next. @@ -700,254 +622,352 @@ class AttendingServerV2 { } /** - * Opens all the queue that the helper has permission to - * @param helperMember helper that used /start - * @param notify whether to notify the students in queue - * @throws ServerError - * - If the helper doesn't have any class roles - * - If the helper is already hosting + * Attempt to enqueue a student + * @param studentMember student member to enqueue + * @param queueChannel target queue + * @throws QueueError if queue refuses to enqueue */ - async openAllOpenableQueues( - helperMember: GuildMember, - notify: boolean + async enqueueStudent( + studentMember: GuildMember, + queueChannel: QueueChannel ): Promise { - if (this._helpers.has(helperMember.id)) { - throw ExpectedServerErrors.alreadyHosting; - } - const helperRoles = helperMember.roles.cache.map(role => role.name); - const openableQueues = this._queues.filter(queue => - helperRoles.includes(queue.queueName) - ); - if (openableQueues.size === 0) { - throw ExpectedServerErrors.missingClassRole; - } - const helper: Helper = { - helpStart: new Date(), - helpedMembers: [], - activeState: 'active', // always start with active state - member: helperMember - }; - this._helpers.set(helperMember.id, helper); - await Promise.all( - openableQueues.map(queue => queue.openQueue(helperMember, notify)) - ); - await Promise.all( - this.serverExtensions.map(extension => - extension.onHelperStartHelping(this, helper) - ) + await this._queues.get(queueChannel.parentCategoryId)?.enqueue(studentMember); + await Promise.all( + this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) ); } /** - * Closes all the queue that the helper has permission to & logs the help time to console - * @param helperMember helper that used /stop - * @throws ServerError if the helper is not hosting + * Gets a queue channel by the parent category id + * @param parentCategoryId the associated parent category id + * @returns queue channel object if it exists, undefined otherwise */ - async closeAllClosableQueues(helperMember: GuildMember): Promise> { - const helper = this._helpers.get(helperMember.id); - if (helper === undefined) { - throw ExpectedServerErrors.notHosting; - } - this._helpers.delete(helperMember.id); - const completeHelper: Required = { - ...helper, - helpEnd: new Date() - }; - console.log( - ` - Help time of ${helper.member.displayName} is ` + - `${convertMsToTime( - completeHelper.helpEnd.getTime() - completeHelper.helpStart.getTime() - )}` - ); - // this filter does not rely on user roles anymore - // close all queues that has this user as a helper - const closableQueues = this._queues.filter(queue => - queue.hasHelper(helperMember.id) - ); - await Promise.all(closableQueues.map(queue => queue.closeQueue(helperMember))); - await Promise.all( - this.serverExtensions.map(extension => - extension.onHelperStopHelping(this, completeHelper) - ) - ); - return completeHelper; + getQueueChannelById(parentCategoryId: Snowflake): Optional { + return this._queues.get(parentCategoryId)?.queueChannel; } /** - * Marks a helper as 'paused'. Used for the '/pause' command - * @param helperMember the active helper to be marked as 'paused' - * @returns whether there are other active helpers - * @throws ServerError if - * - helper is not hosting - * - helper is already paused + * Gets all the queue channels on the server. SLOW + * if nothing is found, returns empty array + * @param useCache whether to read from existing cache, defaults to true + * - unless queues change often, prefer cache for fast response */ - async pauseHelping(helperMember: GuildMember): Promise { - const helper = this._helpers.get(helperMember.id); - if (!helper) { - throw ExpectedServerErrors.notHosting; + async getQueueChannels(useCache = true): Promise { + if (useCache && this.queueChannelsCache.length !== 0) { + return this.queueChannelsCache; } - if (helper.activeState === 'paused') { - throw ExpectedServerErrors.alreadyPaused; + const allChannels = await this.guild.channels.fetch(); + // cache again on a fresh request, likely triggers GC + this.queueChannelsCache = []; + for (const categoryChannel of allChannels.values()) { + if (!isCategoryChannel(categoryChannel)) { + continue; + } + const queueTextChannel: Optional = + categoryChannel.children.cache.find(isQueueTextChannel); + if (!queueTextChannel) { + continue; + } + this.queueChannelsCache.push({ + channelObj: queueTextChannel, + queueName: categoryChannel.name, + parentCategoryId: categoryChannel.id + }); } - helper.activeState = 'paused'; - const pauseableQueues = this._queues.filter(queue => - queue.activeHelperIds.has(helperMember.id) - ); - await Promise.all( - pauseableQueues.map(queue => queue.markHelperAsPaused(helperMember)) - ); - const existOtherActiveHelpers = pauseableQueues.some( - queue => queue.activeHelperIds.size > 0 - ); - return existOtherActiveHelpers; + const duplicateQueues = this.queueChannelsCache + .map(queue => queue.queueName) + .filter((item, index, arr) => arr.indexOf(item) !== index); + if (duplicateQueues.length > 0) { + console.warn(`Server['${this.guild.name}'] contains these duplicate queues:`); + console.warn(duplicateQueues); + console.warn( + `This might lead to unexpected behaviors.\n + Please update category names as soon as possible.` + ); + } + return this.queueChannelsCache; } /** - * Changes the helper state from paused to active - * @param helperMember a paused helper to resume helping + * Called when leaving a server + * - let all the extensions clean up their own memory first before deleting them */ - async resumeHelping(helperMember: GuildMember): Promise { - const helper = this._helpers.get(helperMember.id); - if (!helper) { - throw ExpectedServerErrors.notHosting; - } - if (helper.activeState === 'active') { - throw ExpectedServerErrors.alreadyActive; - } - helper.activeState = 'active'; - const resumableQueues = this._queues.filter(queue => - queue.pausedHelperIds.has(helperMember.id) - ); + async gracefulDelete(): Promise { await Promise.all( - resumableQueues.map(queue => queue.markHelperAsActive(helperMember)) + this.serverExtensions.map(extension => extension.onServerDelete(this)) ); } /** - * Removes a student from a given queue - * @param studentMember student that used /leave or the leave button - * @param targetQueue the queue to leave from - * @throws QueueError: if targetQueue rejects + * Returns true if the server is in serious mode + * @returns */ - async removeStudentFromQueue( + isSeriousServer(): boolean { + return this.queues[0]?.seriousModeEnabled ?? false; + } + + /** + * Notify all helpers of the topic that the student requires help with + * @param studentMember the student that just submitted the help topic modal + * @param queueChannel related queue channel + * @param topic the submitted help topic content + */ + async notifyHelpersStudentHelpTopic( studentMember: GuildMember, - targetQueue: QueueChannel + queueChannel: QueueChannel, + topic: string ): Promise { await this._queues - .get(targetQueue.parentCategoryId) - ?.removeStudent(studentMember); + .get(queueChannel.parentCategoryId) + ?.notifyHelpersOnStudentSubmitHelpTopic(studentMember, topic); + } + + /** + * Called when a member joins a voice channel + * - triggers onStudentJoinVC for all extensions if the member is a + * student and was just removed from the queue + * @param member the guild member that just joined a VC + * @param newVoiceState voice state object with a guaranteed non-null channel + */ + async onMemberJoinVC( + member: GuildMember, + newVoiceState: WithRequired + ): Promise { + // temporary solution, stage channel is not currently supported + if (!isVoiceChannel(newVoiceState.channel)) { + return; + } + const vc = newVoiceState.channel; + const memberIsStudent = this._helpers.some(helper => + helper.helpedMembers.some( + helpedMember => helpedMember.member.id === member.id + ) + ); + const memberIsHelper = this._helpers.has(member.id); + if (memberIsStudent) { + const possibleHelpers = newVoiceState.channel.members.filter( + vcMember => vcMember.id !== member.id + ); + const queuesToRerender = this._queues.filter(queue => + possibleHelpers.some(possibleHelper => queue.hasHelper(possibleHelper.id)) + ); + await Promise.all([ + ...this.serverExtensions.map(extension => + extension.onStudentJoinVC(this, member, vc) + ), + ...queuesToRerender.map(queue => queue.triggerRender()) + ]); + } + if (memberIsHelper) { + await Promise.all( + this.queues.map( + queue => queue.hasHelper(member.id) && queue.triggerRender() + ) + ); + } + } + + /** + * Called when a member leaves a voice channel + * - triggers onStudentLeaveVC for all extensions if the member is a + * student and was in a session + * @param member the guild member that just joined a VC + * @param oldVoiceState voice state object with a guaranteed non-null channel + */ + async onMemberLeaveVC( + member: GuildMember, + oldVoiceState: WithRequired + ): Promise { + const memberIsStudent = this._helpers.some(helper => + helper.helpedMembers.some( + helpedMember => helpedMember.member.id === member.id + ) + ); + const memberIsHelper = this._helpers.has(member.id); + if (memberIsStudent) { + const possibleHelpers = oldVoiceState.channel.members.filter( + vcMember => vcMember.id !== member.id + ); // treat everyone that's not this student as a potential helper + const queuesToRerender = this.queues.filter(queue => + possibleHelpers.some(possibleHelper => queue.hasHelper(possibleHelper.id)) + ); + await Promise.all([ + ...oldVoiceState.channel.permissionOverwrites.cache.map( + overwrite => overwrite.id === member.id && overwrite.delete() + ), // delete the student permission overwrite + ...this.serverExtensions.map(extension => + extension.onStudentLeaveVC(this, member) + ), + this.afterSessionMessage !== '' && + member.send(SimpleEmbed(this.afterSessionMessage)), + ...queuesToRerender.map(queue => queue.triggerRender()) + ]); + } + if (memberIsHelper) { + // the filter is removed because + // the overwrite will die in 15 minutes after the invite was sent + await Promise.all( + this.queues.map( + queue => queue.hasHelper(member.id) && queue.triggerRender() + ) + ); + } + } + + /** + * Checks the deleted `role` was a hierarchy role and if so, mark as deleted + * @param deletedRole the role that was deleted + */ + async onRoleDelete(deletedRole: Role): Promise { + let hierarchyRoleDeleted = false; + // shorthand syntax to take the properties of an object with the same name + for (const { key, id } of this.sortedHierarchyRoles) { + if (deletedRole.id === id) { + this.hierarchyRoleIds[key] = SpecialRoleValues.Deleted; + hierarchyRoleDeleted = true; + } + } + if (!hierarchyRoleDeleted) { + return; + } await Promise.all( this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) ); } /** - * Clears the given queue - * @param targetQueue queue to clear + * Opens all the queue that the helper has permission to + * @param helperMember helper that used /start + * @param notify whether to notify the students in queue + * @throws ServerError + * - If the helper doesn't have any class roles + * - If the helper is already hosting */ - async clearQueue(targetQueue: QueueChannel): Promise { - await this._queues.get(targetQueue.parentCategoryId)?.removeAllStudents(); + async openAllOpenableQueues( + helperMember: GuildMember, + notify: boolean + ): Promise { + if (this._helpers.has(helperMember.id)) { + throw ExpectedServerErrors.alreadyHosting; + } + const helperRoles = helperMember.roles.cache.map(role => role.name); + const openableQueues = this._queues.filter(queue => + helperRoles.includes(queue.queueName) + ); + if (openableQueues.size === 0) { + throw ExpectedServerErrors.missingClassRole; + } + // create this object after all checks have passed + const helper: Helper = { + helpStart: new Date(), + helpedMembers: [], + activeState: 'active', // always start with active state + member: helperMember + }; + this._helpers.set(helperMember.id, helper); await Promise.all( - this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) + openableQueues.map(queue => queue.openQueue(helperMember, notify)) + ); + await Promise.all( + this.serverExtensions.map(extension => + extension.onHelperStartHelping(this, helper) + ) ); } /** - * Clear all queues of this server - * @remark separated from clear queue to avoid excessive backup calls + * Marks a helper as 'paused'. Used for the '/pause' command + * @param helperMember the active helper to be marked as 'paused' + * @returns whether there are other active helpers + * @throws ServerError if + * - helper is not hosting + * - helper is already paused */ - async clearAllQueues(): Promise { - await Promise.all(this._queues.map(queue => queue.removeAllStudents())); + async pauseHelping(helperMember: GuildMember): Promise { + const helper = this._helpers.get(helperMember.id); + if (!helper) { + throw ExpectedServerErrors.notHosting; + } + if (helper.activeState === 'paused') { + throw ExpectedServerErrors.alreadyPaused; + } + helper.activeState = 'paused'; + const pauseableQueues = this._queues.filter(queue => + queue.activeHelperIds.has(helperMember.id) + ); await Promise.all( - this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) + pauseableQueues.map(queue => queue.markHelperAsPaused(helperMember)) + ); + const existOtherActiveHelpers = pauseableQueues.some( + queue => queue.activeHelperIds.size > 0 ); + return existOtherActiveHelpers; } /** - * Adds a student to the notification group + * Removes a student from the notification group * @param studentMember student to add - * @param targetQueue which notification group to add to + * @param targetQueue which notification group to remove from */ - async addStudentToNotifGroup( + async removeStudentFromNotifGroup( studentMember: GuildMember, targetQueue: QueueChannel ): Promise { await this._queues .get(targetQueue.parentCategoryId) - ?.addToNotifGroup(studentMember); + ?.removeFromNotifGroup(studentMember); } /** - * Removes a student from the notification group - * @param studentMember student to add - * @param targetQueue which notification group to remove from + * Removes a student from a given queue + * @param studentMember student that used /leave or the leave button + * @param targetQueue the queue to leave from + * @throws QueueError: if targetQueue rejects */ - async removeStudentFromNotifGroup( + async removeStudentFromQueue( studentMember: GuildMember, targetQueue: QueueChannel ): Promise { await this._queues .get(targetQueue.parentCategoryId) - ?.removeFromNotifGroup(studentMember); + ?.removeStudent(studentMember); + await Promise.all( + this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) + ); } /** - * Send an announcement to all the students in the helper's approved queues - * @param helperMember helper that used /announce - * @param announcement announcement message - * @param targetQueue optional, specifies which queue to announce to + * Changes the helper state from paused to active + * @param helperMember a paused helper to resume helping */ - async announceToStudentsInQueue( - helperMember: GuildMember, - announcement: string, - targetQueue?: QueueChannel - ): Promise { - if (targetQueue !== undefined) { - const queueToAnnounce = this._queues.get(targetQueue.parentCategoryId); - const hasQueueRole = helperMember.roles.cache.some( - role => - role.name === targetQueue.queueName || role.id === this.botAdminRoleID - ); - if (!queueToAnnounce || !hasQueueRole) { - throw ExpectedServerErrors.noAnnouncePerm(targetQueue.queueName); - } - const announcementEmbed = SimpleEmbed( - `Staff member ${helperMember.displayName} announced:\n${announcement}`, - EmbedColor.Aqua, - `In queue: ${targetQueue.queueName}` - ); - await Promise.all( - queueToAnnounce.students.map(student => - student.member.send(announcementEmbed) - ) - ); - return; + async resumeHelping(helperMember: GuildMember): Promise { + const helper = this._helpers.get(helperMember.id); + if (!helper) { + throw ExpectedServerErrors.notHosting; } - // from this.queues select queue where helper roles has queue name - const studentsToAnnounceTo = this.queues - .filter(queue => - helperMember.roles.cache.some(role => role.name === queue.queueName) - ) - .flatMap(queueToAnnounce => queueToAnnounce.students); - if (studentsToAnnounceTo.length === 0) { - throw ExpectedServerErrors.noStudentToAnnounce(announcement); + if (helper.activeState === 'active') { + throw ExpectedServerErrors.alreadyActive; } - const announcementEmbed = SimpleEmbed( - `Staff member ${helperMember.displayName} announced:`, - EmbedColor.Aqua, - announcement + helper.activeState = 'active'; + const resumableQueues = this._queues.filter(queue => + queue.pausedHelperIds.has(helperMember.id) ); await Promise.all( - studentsToAnnounceTo.map(student => student.member.send(announcementEmbed)) + resumableQueues.map(queue => queue.markHelperAsActive(helperMember)) ); } /** - * Cleans up the given queue and resend all embeds - * @param targetQueue the queue to clean + * Sends the log message to the logging channel if it's set up + * @param message message to log */ - async cleanUpQueue(targetQueue: QueueChannel): Promise { - await this._queues.get(targetQueue.parentCategoryId)?.triggerForceRender(); + sendLogMessage(message: BaseMessageOptions | string): void { + if (this.loggingChannel) { + this.loggingChannel.send(message).catch(e => { + console.error(red(`Failed to send logs to ${this.guild.name}.`)); + console.error(e); + }); + } } /** @@ -963,30 +983,32 @@ class AttendingServerV2 { } /** - * Sets up queue auto clear for this server - * @param hours the number of hours to wait before clearing the queue - * @param minutes the number of minutes to wait before clearing the queue - * @param enable whether to disable auto clear, overrides 'hours' + * Sets the internal boolean value for autoGiveStudentRole + * @param autoGiveStudentRole on or off */ - async setQueueAutoClear( - hours: number, - minutes: number, - enable: boolean - ): Promise { - this._queues.forEach(queue => queue.setAutoClear(hours, minutes, enable)); + async setAutoGiveStudentRole(autoGiveStudentRole: boolean): Promise { + this.settings.autoGiveStudentRole = autoGiveStudentRole; await Promise.all( this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) ); } /** - * Called when leaving a server - * - let all the extensions clean up their own memory first before deleting them + * Sets the hierarchy roles to use for this server + * @param role name of the role; botAdmin, staff, or student + * @param id the role id snowflake */ - async gracefulDelete(): Promise { - await Promise.all( - this.serverExtensions.map(extension => extension.onServerDelete(this)) - ); + async setHierarchyRoleId(role: keyof HierarchyRoles, id: Snowflake): Promise { + this.settings.hierarchyRoleIds[role] = id; + await Promise.all([ + updateCommandHelpChannelVisibility( + this.guild, + this.settings.hierarchyRoleIds + ), + ...this.serverExtensions.map(extension => + extension.onServerRequestBackup(this) + ) + ]); } /** @@ -1002,75 +1024,52 @@ class AttendingServerV2 { } /** - * Creates all the command access hierarchy roles - * @param allowDuplicate if true, creates new roles even if they already exist - * - Duplicates will be created if roles with the same name already exist - * @param everyoneIsStudent whether to treat @ everyone as the student role + * Sets the internal boolean value for promptHelpTopic + * @param promptHelpTopic */ - async createHierarchyRoles( - allowDuplicate: boolean, - everyoneIsStudent: boolean - ): Promise { - const allRoles = await this.guild.roles.fetch(); - const everyoneRoleId = this.guild.roles.everyone.id; - const foundRoles = []; // sorted low to high - const createdRoles = []; // not typed bc we are only using it for logging - for (const role of this.sortedHierarchyRoles) { - // do the search if it's NotSet, Deleted, or @everyone - // so if existingRole is not undefined, it's one of @Bot Admin, @Staff or @Student - const existingRole = - (Object.values(SpecialRoleValues) as string[]).includes(role.id) || - role.id === everyoneRoleId - ? allRoles.find(serverRole => serverRole.name === role.displayName) - : allRoles.get(role.id); - if (role.key === 'student' && everyoneIsStudent) { - this.hierarchyRoleIds.student = everyoneRoleId; - continue; - } - if (existingRole && !allowDuplicate) { - this.hierarchyRoleIds[role.key] = existingRole.id; - foundRoles[existingRole.position] = existingRole.name; - continue; - } - const newRole = await this.guild.roles.create({ - ...hierarchyRoleConfigs[role.key], - name: hierarchyRoleConfigs[role.key].displayName - }); - this.hierarchyRoleIds[role.key] = newRole.id; - // set by indices that are larger than arr length is valid in JS - // ! do NOT do this with important arrays bc there will be 'empty items' - createdRoles[newRole.position] = newRole.name; - } + async setPromptHelpTopic(promptHelpTopic: boolean): Promise { + this.settings.promptHelpTopic = promptHelpTopic; await Promise.all( this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) ); - createdRoles.length > 0 - ? console.log(blue('Created roles:'), createdRoles) - : console.log(green(`All required roles exist in ${this.guild.name}!`)); - foundRoles.length > 0 && console.log('Found roles:', foundRoles); } /** - * Checks the deleted `role` was a hierarchy role and if so, mark as deleted - * @param deletedRole the role that was deleted + * Sets up queue auto clear for this server + * @param hours the number of hours to wait before clearing the queue + * @param minutes the number of minutes to wait before clearing the queue + * @param enable whether to disable auto clear, overrides 'hours' */ - async onRoleDelete(deletedRole: Role): Promise { - let hierarchyRoleDeleted = false; - // shorthand syntax to take the properties of an object with the same name - for (const { key, id } of this.sortedHierarchyRoles) { - if (deletedRole.id === id) { - this.hierarchyRoleIds[key] = SpecialRoleValues.Deleted; - hierarchyRoleDeleted = true; - } - } - if (!hierarchyRoleDeleted) { - return; - } + async setQueueAutoClear( + hours: number, + minutes: number, + enable: boolean + ): Promise { + this._queues.forEach(queue => queue.setAutoClear(hours, minutes, enable)); await Promise.all( this.serverExtensions.map(extension => extension.onServerRequestBackup(this)) ); } + /** + * Sets the serious server flag, and updates the queues if seriousness is changed + * @param enableSeriousMode turn on or off serious mode + * @returns True if triggered renders for all queues + */ + async setSeriousServer(enableSeriousMode: boolean): Promise { + const seriousState = this.queues[0]?.seriousModeEnabled ?? false; + if (seriousState === enableSeriousMode) { + return false; + } + await Promise.all([ + ...this._queues.map(queue => queue.setSeriousMode(enableSeriousMode)), + ...this.serverExtensions.map(extension => + extension.onServerRequestBackup(this) + ) + ]); + return true; + } + /** * Creates roles for all the available queues if not already created */ @@ -1132,6 +1131,29 @@ class AttendingServerV2 { ) ); } + + /** + * Loads the server settings data from a backup + * - queue backups are passed to the queue constructors + * @param backup the data to load + */ + private loadBackup(backup: ServerBackup): void { + console.log(cyan(`Found external backup for ${this.guild.name}. Restoring.`)); + this.settings = { + ...backup, + hierarchyRoleIds: { + botAdmin: backup.botAdminRoleId, + staff: backup.staffRoleId, + student: backup.studentRoleId + } + }; + const loggingChannelFromBackup = this.guild.channels.cache.get( + backup.loggingChannelId + ); + if (isTextChannel(loggingChannelFromBackup)) { + this.settings.loggingChannel = loggingChannelFromBackup; + } + } } export { AttendingServerV2, QueueChannel }; diff --git a/src/attending-server/server-settings-menus.ts b/src/attending-server/server-settings-menus.ts index fa2b9504..042b611a 100644 --- a/src/attending-server/server-settings-menus.ts +++ b/src/attending-server/server-settings-menus.ts @@ -60,7 +60,9 @@ const documentationLinks = { autoClear: `${documentationBaseUrl}#queue-auto-clear`, loggingChannel: `${documentationBaseUrl}#logging-channel`, afterSessionMessage: `${documentationBaseUrl}#after-session-message`, - autoGiveStudentRole: `${documentationBaseUrl}#auto-give-student-role` + autoGiveStudentRole: `${documentationBaseUrl}#auto-give-student-role`, + promptHelpTopic: `${documentationBaseUrl}#help-topic-prompt`, + seriousMode: `${documentationBaseUrl}#serious-mode` }; /** @@ -111,6 +113,24 @@ const serverSettingsMainMenuOptions: SettingsMenuOption[] = [ value: 'auto-give-student-role' }, subMenu: AutoGiveStudentRoleConfigMenu + }, + { + optionData: { + emoji: '🙋', + label: 'Help Topic Prompt', + description: 'Configure the help topic prompt', + value: 'help-topic-prompt' + }, + subMenu: PromptHelpTopicConfigMenu + }, + { + optionData: { + emoji: '🧐', + label: 'Serious Mode', + description: 'Configure the serious mode', + value: 'serious-mode' + }, + subMenu: SeriousModeConfigMenu } ]; @@ -626,6 +646,14 @@ function LoggingChannelConfigMenu( }; } +/** + * Composes the auto give student role configuration menu + * @param server + * @param channelId + * @param isDm + * @param updateMessage + * @returns + */ function AutoGiveStudentRoleConfigMenu( server: AttendingServerV2, channelId: string, @@ -677,6 +705,125 @@ function AutoGiveStudentRoleConfigMenu( return { embeds: [embed.data], components: [buttons, mainMenuRow] }; } +/** + * Composes the help topic prompt configuration menu + * @param server + * @param channelId + * @param isDm + * @param updateMessage + * @returns + */ +function PromptHelpTopicConfigMenu( + server: AttendingServerV2, + channelId: string, + isDm: boolean, + updateMessage = '' +): YabobEmbed { + const embed = new EmbedBuilder() + .setTitle(`🙋 Help Topic Prompt Configuration for ${server.guild.name} 🙋`) + .setColor(EmbedColor.Aqua) + .addFields( + { + name: 'Description', + value: `Whether to prompt students to select a help topic when they join the queue.` + }, + { + name: 'Documentation', + value: `[Learn more about help topic prompts here.](${documentationLinks.promptHelpTopic})` //TODO: Add documentation link + }, + { + name: 'Current Configuration', + value: server.promptHelpTopic + ? `**Enabled** - Students will be prompted to enter a help topic when they join the queue.` + : `**Disabled** - Students will not be prompted when they join the queue.` + } + ); + if (updateMessage.length > 0) { + embed.setFooter({ text: `✅ ${updateMessage}` }); + } + const buttons = new ActionRowBuilder().addComponents( + buildComponent(new ButtonBuilder(), [ + isDm ? 'dm' : 'other', + ButtonNames.PromptHelpTopicConfig1, + server.guild.id, + channelId + ]) + .setEmoji('🔓') + .setLabel('Enable') + .setStyle(ButtonStyle.Secondary), + buildComponent(new ButtonBuilder(), [ + isDm ? 'dm' : 'other', + ButtonNames.PromptHelpTopicConfig2, + server.guild.id, + channelId + ]) + .setEmoji('🔒') + .setLabel('Disable') + .setStyle(ButtonStyle.Secondary) + ); + return { embeds: [embed.data], components: [buttons, mainMenuRow] }; +} + +/** + * Composes the serious mode configuration menu + * @param server + * @param channelId + * @param isDm + * @param updateMessage + * @returns + */ +function SeriousModeConfigMenu( + server: AttendingServerV2, + channelId: string, + isDm: boolean, + updateMessage = '' +): YabobEmbed { + const embed = new EmbedBuilder() + .setTitle(`🧐 Serious Mode Configuration for ${server.guild.name} 🧐`) + .setColor(EmbedColor.Aqua) + .addFields( + { + name: 'Description', + value: `When serious mode is enabled, YABOB will not use emojis or emoticons for fun purposes (e.g. Sleeping emoticon when queue is closed). This will also disable any commands that\ + are not related to queue management` + }, + { + name: 'Documentation', + value: `[Learn more about serious mode here.](${documentationLinks.seriousMode})` //TODO: Add documentation link + }, + { + name: 'Current Configuration', + value: server.isSeriousServer() + ? `**Enabled** - YABOB will not use emojis or emoticons for fun purposes.` + : `**Disabled** - YABOB can use emojis and emoticons for fun purposes.` + } + ); + if (updateMessage.length > 0) { + embed.setFooter({ text: `✅ ${updateMessage}` }); + } + const buttons = new ActionRowBuilder().addComponents( + buildComponent(new ButtonBuilder(), [ + isDm ? 'dm' : 'other', + ButtonNames.SeriousModeConfig1, + server.guild.id, + channelId + ]) + .setEmoji('🔓') + .setLabel('Enable') + .setStyle(ButtonStyle.Secondary), + buildComponent(new ButtonBuilder(), [ + isDm ? 'dm' : 'other', + ButtonNames.SeriousModeConfig2, + server.guild.id, + channelId + ]) + .setEmoji('🔒') + .setLabel('Disable') + .setStyle(ButtonStyle.Secondary) + ); + return { embeds: [embed.data], components: [buttons, mainMenuRow] }; +} + export { SettingsMainMenu, RolesConfigMenu, @@ -685,6 +832,8 @@ export { QueueAutoClearConfigMenu, LoggingChannelConfigMenu, AutoGiveStudentRoleConfigMenu, + PromptHelpTopicConfigMenu as PromptHelpTopicConfigMenu, + SeriousModeConfigMenu, mainMenuRow, serverSettingsMainMenuOptions }; diff --git a/src/extensions/google-sheet-logging/google-sheet-constants/expected-sheet-errors.ts b/src/extensions/google-sheet-logging/google-sheet-constants/expected-sheet-errors.ts index 584ff3bd..56604360 100644 --- a/src/extensions/google-sheet-logging/google-sheet-constants/expected-sheet-errors.ts +++ b/src/extensions/google-sheet-logging/google-sheet-constants/expected-sheet-errors.ts @@ -37,6 +37,7 @@ const ExpectedSheetErrors = { badGoogleSheetId: new GoogleSheetConnectionError( `YABOB cannot access this google sheet. Make sure you share the google sheet with this YABOB's email: \`${environment.googleCloudCredentials.client_email}\`` ), + unparsableDateString: (sheetName:string) => new CommandParseError(`Hmmm...YABOB cannot parse the data stored in ${sheetName}. Is the data format altered?`), nonServerInteraction: (guildName?: string) => guildName === undefined ? new CommandParseError( diff --git a/src/extensions/google-sheet-logging/google-sheet-constants/google-sheet-settings-menu.ts b/src/extensions/google-sheet-logging/google-sheet-constants/google-sheet-settings-menu.ts new file mode 100644 index 00000000..bb5b4fbe --- /dev/null +++ b/src/extensions/google-sheet-logging/google-sheet-constants/google-sheet-settings-menu.ts @@ -0,0 +1,70 @@ +import { EmbedBuilder } from 'discord.js'; +import { EmbedColor } from '../../../utils/embed-helper.js'; +import { SettingsMenuOption, YabobEmbed } from '../../../utils/type-aliases.js'; +import { FrozenServer } from '../../extension-utils.js'; +import { GoogleSheetExtensionState } from '../google-sheet-states.js'; +import { mainMenuRow } from '../../../attending-server/server-settings-menus.js'; + +/** + * Options for the server settings main menu + * @see {@link serverSettingsMainMenuOptions} + */ +const googleSheetSettingsMainMenuOptions: SettingsMenuOption[] = [ + { + optionData: { + emoji: '📊', + label: 'Google Sheet Logging Settings', + description: 'Configure the Google Sheet Logging settings', + value: 'google-sheet-settings' + }, + subMenu: GoogleSheetSettingsConfigMenu + } +]; + +/** Compose the Google Sheet Logging settings settings menu */ +function GoogleSheetSettingsConfigMenu( + server: FrozenServer, + channelId: string, + isDm: boolean, + updateMessage = '' +): YabobEmbed { + const state = GoogleSheetExtensionState.allStates.get(server.guild.id); + if (!state) { + throw new Error('Google Sheet Logging state for this server was not found'); + } + const setGoogleSheetCommandID = server.guild.commands.cache.find( + command => command.name === 'set_google_sheet' + )?.id; + const embed = new EmbedBuilder() + .setTitle(`📊 Google Sheet Logging Configuration for ${server.guild.name} 📊`) + .setColor(EmbedColor.Aqua) + .setFields( + { + name: 'Description', + value: 'This setting controls which Google Sheet this server will be used for logging.' + }, + { + name: 'Documentation', + value: `[Learn more about Google Sheet Logging settings here.](https://github.com/KaoushikMurugan/yet-another-better-office-hour-bot/wiki/Configure-YABOB-Settings-For-Your-Server#google-sheet-settings)` //TODO: Add link to documentation + }, + { + name: 'Current Google Sheet', + value: + `[Google Sheet](${state.googleSheetURL}) \n ` + + `To change the Google Sheet, please use the ${ + setGoogleSheetCommandID + ? `` + : '`/set_google_sheet`' + } command. A select menu will be added in v4.3.1` + } + ); + if (updateMessage.length > 0) { + embed.setFooter({ text: `✅ ${updateMessage}` }); + } + return { + embeds: [embed], + components: [mainMenuRow] + }; +} + +export { googleSheetSettingsMainMenuOptions, GoogleSheetSettingsConfigMenu }; diff --git a/src/extensions/google-sheet-logging/google-sheet-interaction-extension.ts b/src/extensions/google-sheet-logging/google-sheet-interaction-extension.ts index 5dea4d34..f2578160 100644 --- a/src/extensions/google-sheet-logging/google-sheet-interaction-extension.ts +++ b/src/extensions/google-sheet-logging/google-sheet-interaction-extension.ts @@ -5,6 +5,7 @@ import { BaseInteractionExtension, IInteractionExtension } from '../extension-interface.js'; +import { googleSheetSettingsMainMenuOptions } from './google-sheet-constants/google-sheet-settings-menu.js'; import { googleSheetsCommands } from './google-sheet-constants/google-sheet-slash-commands.js'; import { googleSheetCommandMap } from './interaction-handling/command-handler.js'; import { loadSheetById } from './shared-sheet-functions.js'; @@ -48,6 +49,8 @@ class GoogleSheetInteractionExtension override slashCommandData = googleSheetsCommands; override commandMap = googleSheetCommandMap; + + override settingsMainMenuOptions = googleSheetSettingsMainMenuOptions; } export { GoogleSheetInteractionExtension }; diff --git a/src/extensions/google-sheet-logging/google-sheet-states.ts b/src/extensions/google-sheet-logging/google-sheet-states.ts index 4f9a2b49..17dbf091 100644 --- a/src/extensions/google-sheet-logging/google-sheet-states.ts +++ b/src/extensions/google-sheet-logging/google-sheet-states.ts @@ -50,6 +50,12 @@ class GoogleSheetExtensionState { get googleSheet(): GoogleSpreadsheet { return this._googleSheet; } + /** + * The public url of the google sheet document + */ + get googleSheetURL(): string { + return `https://docs.google.com/spreadsheets/d/${this.googleSheet.spreadsheetId}`; + } /** * Loads the extension states for 1 guild diff --git a/src/extensions/google-sheet-logging/interaction-handling/command-handler.ts b/src/extensions/google-sheet-logging/interaction-handling/command-handler.ts index 9e2b61a1..2154c277 100644 --- a/src/extensions/google-sheet-logging/interaction-handling/command-handler.ts +++ b/src/extensions/google-sheet-logging/interaction-handling/command-handler.ts @@ -9,6 +9,7 @@ import { isServerGoogleSheetInteraction } from '../shared-sheet-functions.js'; import { GoogleSheetSuccessMessages } from '../google-sheet-constants/sheet-success-messages.js'; +import { ExpectedSheetErrors } from '../google-sheet-constants/expected-sheet-errors.js'; const googleSheetCommandMap: CommandHandlerProps = { methodMap: { @@ -38,21 +39,21 @@ async function getStatistics( if (!googleSheet) { throw new Error( `No google sheet found for server ${server.guild.name}. ` + - `Did you forget to set the google sheet id in the environment?` + `Did you forget to set the google sheet id?` ); } - const sheetTitle = `${server.guild.name.replace(/:/g, ' ')} Attendance`.replace( - /\s{2,}/g, + const attendanceSheetTitle = `${server.guild.name.replace( + /:/g, ' ' - ); + )} Attendance`.replace(/\s{2,}/g, ' '); - const attendanceSheet = googleSheet.sheetsByTitle[sheetTitle]; + const attendanceSheet = googleSheet.sheetsByTitle[attendanceSheetTitle]; if (!attendanceSheet) { throw new Error( - `No help session sheet found for server ${server.guild.name}. ` + - `Did you forget to set the google sheet id in the environment?` + `No attendance sheet found for server ${server.guild.name}. ` + + `Did you forget to set the google sheet id` ); } @@ -70,9 +71,9 @@ async function getStatistics( const timeFrame = interaction.options.getString('time_frame') ?? 'all-time'; - const rows = await attendanceSheet.getRows(); + const attendanceRows = await attendanceSheet.getRows(); - let filteredRows = rows.filter(row => { + let filteredAttendanceRows = attendanceRows.filter(row => { return user ? row['Discord ID'] === user.id : true; }); @@ -87,7 +88,7 @@ async function getStatistics( startTime.setTime(0); } - filteredRows = filteredRows.filter(row => { + filteredAttendanceRows = filteredAttendanceRows.filter(row => { // the row 'Time In' is in the format 'MM/DD/YYYY, HH:MM:SS AM/PM' const returnDate = row['Time In'].split(',')[0]; const returnTime = row['Time In'].split(',')[1]; @@ -105,7 +106,7 @@ async function getStatistics( return returnDateObj >= startTime; }); - if (filteredRows.length === 0) { + if (filteredAttendanceRows.length === 0) { await interaction.editReply( SimpleEmbed( `No help sessions found for ${user?.username ?? server.guild.name}`, @@ -114,24 +115,19 @@ async function getStatistics( ); } - const helpSessionCount = filteredRows.length; + const helperSessionCount = filteredAttendanceRows.length; - const totalSessionTime = filteredRows + const totalAvailableTime = filteredAttendanceRows .map(row => { return parseInt(row['Session Time (ms)']); }) .filter((time: number) => !isNaN(time)) .reduce((a, b) => a + b, 0); - const totalSessionTimeHours = Math.trunc(totalSessionTime / (1000 * 60 * 60)); - const totalSessionTimeMinutes = Math.trunc(totalSessionTime / (1000 * 60)) % 60; - - const averageSessionTime = totalSessionTime / helpSessionCount; + const totalAvailableTimeHours = Math.trunc(totalAvailableTime / (1000 * 60 * 60)); + const totalAvailableTimeMinutes = Math.trunc(totalAvailableTime / (1000 * 60)) % 60; - const averageSessionTimeHours = Math.trunc(averageSessionTime / (1000 * 60 * 60)); - const averageSessionTimeMinutes = Math.trunc(averageSessionTime / (1000 * 60)) % 60; - - const numberOfStudents = filteredRows + const numberOfStudents = filteredAttendanceRows .map(row => { return parseInt(row['Number of Students Helped']); }) @@ -140,9 +136,9 @@ async function getStatistics( const studentsList: string[] = []; - // each cell in the 'Helped Student' is an arry of json strings, where the json is of the form + // each cell in the 'Helped Student' is an array of json strings, where the json is of the form // {displayName: string, username: string, id: string} - filteredRows.forEach(row => { + filteredAttendanceRows.forEach(row => { const students = row['Helped Students']; if (students) { const studentArray = JSON.parse(students); @@ -159,19 +155,105 @@ async function getStatistics( studentsList.filter((student, index) => studentsList.indexOf(student) !== index) ); + // -------------------------- + // Reading Help Session Sheet + // -------------------------- + + const helpSessionSheetTitle = `${server.guild.name.replace( + /:/g, + ' ' + )} Help Sessions`.replace(/\s{2,}/g, ' '); + + const helpSessionSheet = googleSheet.sheetsByTitle[helpSessionSheetTitle]; + + if (!helpSessionSheet) { + throw new Error( + `No help session sheet found for server ${server.guild.name}. ` + + `Did you forget to set the google sheet id?` + ); + } + + const helpSessionRows = await helpSessionSheet.getRows(); + + let filteredHelpSessionRows = helpSessionRows.filter(row => { + return user ? row['Helper Discord ID'] === user.id : true; + }); + + filteredHelpSessionRows = filteredHelpSessionRows.filter(row => { + // the row 'Session Start' is in the format 'MM/DD/YYYY, HH:MM:SS AM/PM' + const returnDate = row['Session Start'].split(',')[0]; + const returnTime = row['Session Start'].split(',')[1]; + const returnDateParts = returnDate.split('/'); + const returnTimeParts = returnTime.split(':'); + const returnDateObj = new Date( + parseInt(returnDateParts[2]), + parseInt(returnDateParts[0]) - 1, + parseInt(returnDateParts[1]), + parseInt(returnTimeParts[0]), + parseInt(returnTimeParts[1]), + parseInt(returnTimeParts[2].split(' ')[0]) + ); + + return returnDateObj >= startTime; + }); + + if (filteredAttendanceRows.length === 0) { + await interaction.editReply( + SimpleEmbed( + `No help sessions found for ${user?.username ?? server.guild.name}`, + EmbedColor.Neutral + ) + ); + } + + const helpSessionCount = filteredHelpSessionRows.length; + + const totalSessionTime = filteredHelpSessionRows + .map(row => { + return parseInt(row['Session Time (ms)']); + }) + .filter((time: number) => !isNaN(time)) + .reduce((a, b) => a + b, 0); + + const totalSessionTimeHours = Math.trunc(totalSessionTime / (1000 * 60 * 60)); + const totalSessionTimeMinutes = Math.trunc(totalSessionTime / (1000 * 60)) % 60; + + const averageSessionTime = totalSessionTime / helpSessionCount; + + const averageSessionTimeHours = Math.trunc(averageSessionTime / (1000 * 60 * 60)); + const averageSessionTimeMinutes = Math.trunc(averageSessionTime / (1000 * 60)) % 60; + + const totalWaitTime = filteredHelpSessionRows + .map(row => { + return parseInt(row['Wait Time (ms)']); + }) + .filter((time: number) => !isNaN(time)) + .reduce((a, b) => a + b, 0); + + const averageWaitTime = totalWaitTime / helpSessionCount; + + const averageWaitTimeHours = Math.trunc(averageWaitTime / (1000 * 60 * 60)); + const averageWaitTimeMinutes = Math.trunc(averageWaitTime / (1000 * 60)) % 60; + const result = SimpleEmbed( `Help session statistics for ` + `${user ? user.username : server.guild.name}`, EmbedColor.Neutral, - `Help sessions: **${helpSessionCount}**\n` + - `Total session time: **${ + `Help sessions: **${helperSessionCount}**\n` + + `Total available time: **${ + totalAvailableTimeHours > 0 ? `${totalAvailableTimeHours} h ` : '' + }${totalAvailableTimeMinutes} min**\n` + + `Total helping time: **${ totalSessionTimeHours > 0 ? `${totalSessionTimeHours} h ` : '' - }${totalSessionTimeMinutes} min**\n` + - `Number of students sessions: **${numberOfStudents}**\n` + + }${totalSessionTimeMinutes} min**\n\n` + + `Number of student sessions: **${numberOfStudents}**\n` + `Unique students helped: **${uniqueStudents.size}**\n` + - `Returning students: **${returningStudents.size}**\n` + + `Returning students: **${returningStudents.size}**\n\n` + `Average session time: **${ averageSessionTimeHours > 0 ? `${averageSessionTimeHours} h ` : '' - }${averageSessionTimeMinutes} min**\n` + }${averageSessionTimeMinutes} min**\n` + + `Average wait time: **${ + averageWaitTimeHours > 0 ? `${averageWaitTimeHours} h ` : '' + }${averageWaitTimeMinutes} min**\n` ); await interaction.editReply(result); } @@ -241,23 +323,35 @@ async function getWeeklyReport( startTime.getDate() - (startTime.getDay() % 7) + startOfWeek - 7 * numWeeks ); - filteredRows = filteredRows.filter(row => { - // the row 'Time In' is in the format 'MM/DD/YYYY, HH:MM:SS AM/PM' - const returnDate = row['Time In'].split(',')[0]; - const returnTime = row['Time In'].split(',')[1]; - const returnDateParts = returnDate.split('/'); - const returnTimeParts = returnTime.split(':'); - const returnDateObj = new Date( - parseInt(returnDateParts[2]), - parseInt(returnDateParts[0]) - 1, - parseInt(returnDateParts[1]), - parseInt(returnTimeParts[0]), - parseInt(returnTimeParts[1]), - parseInt(returnTimeParts[2].split(' ')[0]) - ); - - return returnDateObj >= startTime; - }); + try { + filteredRows = filteredRows.filter(row => { + // the row 'Time In' is in the format 'MM/DD/YYYY, HH:MM:SS AM/PM' + // TODO: add validation to indexing rows + const returnDate = row['Time In'].split(',')[0]; // this could be undefined invoking split on undefined will throw exception + const returnTime = row['Time In'].split(',')[1]; // this could be undefined + const returnDateParts = returnDate.split('/'); + const returnTimeParts = returnTime.split(':'); + const returnDateObj = new Date( + // FIXME: all of this could be NaN + parseInt(returnDateParts[2]), + parseInt(returnDateParts[0]) - 1, + parseInt(returnDateParts[1]), + parseInt(returnTimeParts[0]), + parseInt(returnTimeParts[1]), + parseInt(returnTimeParts[2].split(' ')[0]) + ); + // Date constructor never throws exception, but returns the "Invalid Date" string instead + // need to be manually checked, TS design limitation here + if (!(returnDateObj instanceof Date)) { + // TODO: Temporary solution, if any parsing fails, throw this error instead + throw ExpectedSheetErrors.unparsableDateString(attendanceSheet.title); + } + return returnDateObj >= startTime; + }); + } catch { + // TODO: Temporary solution, if any parsing fails, throw this error instead + throw ExpectedSheetErrors.unparsableDateString(attendanceSheet.title); + } if (filteredRows.length === 0) { await interaction.editReply( @@ -284,14 +378,15 @@ async function getWeeklyReport( const weekEndTime = new Date(startTime); weekEndTime.setDate(weekEndTime.getDate() + 7 * (i + 1)); - const weekRows = filteredRows.filter(row => { // the row 'Time In' is in the format 'MM/DD/YYYY, HH:MM:SS AM/PM' - const returnDate = row['Time In'].split(',')[0]; - const returnTime = row['Time In'].split(',')[1]; - const returnDateParts = returnDate.split('/'); - const returnTimeParts = returnTime.split(':'); + // TODO: remove excessive optional chaining + const returnDate = row['Time In']?.split(',')[0]; + const returnTime = row['Time In']?.split(',')[1]; + const returnDateParts = returnDate?.split('/'); + const returnTimeParts = returnTime?.split(':'); const returnDateObj = new Date( + // FIXME: All of this could be NaN parseInt(returnDateParts[2]), parseInt(returnDateParts[0]) - 1, parseInt(returnDateParts[1]), @@ -300,9 +395,13 @@ async function getWeeklyReport( parseInt(returnTimeParts[2].split(' ')[0]) ); //TODO: remove manual parsing + // need to be manually checked + if (!(returnDateObj instanceof Date)) { + throw ExpectedSheetErrors.unparsableDateString(attendanceSheet.title); + } + return returnDateObj >= weekStartTime && returnDateObj <= weekEndTime; }); - const weekSessions = weekRows.length; const weekTime = weekRows diff --git a/src/interaction-handling/button-handler.ts b/src/interaction-handling/button-handler.ts index c6966632..4ec23b59 100644 --- a/src/interaction-handling/button-handler.ts +++ b/src/interaction-handling/button-handler.ts @@ -6,7 +6,9 @@ import { QueueAutoClearConfigMenu, LoggingChannelConfigMenu, AutoGiveStudentRoleConfigMenu, - RolesConfigMenuForServerInit + RolesConfigMenuForServerInit, + PromptHelpTopicConfigMenu, + SeriousModeConfigMenu } from '../attending-server/server-settings-menus.js'; import { ButtonHandlerProps } from './handler-interface.js'; import { @@ -18,7 +20,7 @@ import { ButtonNames } from './interaction-constants/interaction-names.js'; import { SuccessMessages } from './interaction-constants/success-messages.js'; import { afterSessionMessageModal, - helpTopicPromptModal, + promptHelpTopicModal, queueAutoClearModal } from './interaction-constants/modal-objects.js'; import { SimpleEmbed } from '../utils/embed-helper.js'; @@ -49,7 +51,15 @@ const baseYabobButtonMethodMap: ButtonHandlerProps = { [ButtonNames.AutoGiveStudentRoleConfig2]: interaction => toggleAutoGiveStudentRole(interaction, false), [ButtonNames.ShowAfterSessionMessageModal]: showAfterSessionMessageModal, - [ButtonNames.ShowQueueAutoClearModal]: showQueueAutoClearModal + [ButtonNames.ShowQueueAutoClearModal]: showQueueAutoClearModal, + [ButtonNames.PromptHelpTopicConfig1]: interaction => + togglePromptHelpTopic(interaction, true), + [ButtonNames.PromptHelpTopicConfig2]: interaction => + togglePromptHelpTopic(interaction, false), + [ButtonNames.SeriousModeConfig1]: interaction => + toggleSeriousMode(interaction, true), + [ButtonNames.SeriousModeConfig2]: interaction => + toggleSeriousMode(interaction, false) } }, dmMethodMap: { @@ -75,7 +85,11 @@ const baseYabobButtonMethodMap: ButtonHandlerProps = { ButtonNames.ShowQueueAutoClearModal, ButtonNames.DisableLoggingChannel, ButtonNames.AutoGiveStudentRoleConfig1, - ButtonNames.AutoGiveStudentRoleConfig2 + ButtonNames.AutoGiveStudentRoleConfig2, + ButtonNames.PromptHelpTopicConfig1, + ButtonNames.PromptHelpTopicConfig2, + ButtonNames.SeriousModeConfig1, + ButtonNames.SeriousModeConfig2 ]) } as const; @@ -95,7 +109,7 @@ async function join(interaction: ButtonInteraction<'cached'>): Promise { } await server.enqueueStudent(interaction.member, queueChannel); server.promptHelpTopic - ? await interaction.showModal(helpTopicPromptModal(server.guild.id)) + ? await interaction.showModal(promptHelpTopicModal(server.guild.id)) : await interaction.editReply( SuccessMessages.joinedQueue(queueChannel.queueName) ); @@ -284,4 +298,43 @@ async function toggleAutoGiveStudentRole( ); } +/** + * Toggle whether to prompt for help topic when a student joins the queue + * @param interaction + * @param enablePromptHelpTopic + */ +async function togglePromptHelpTopic( + interaction: ButtonInteraction<'cached'>, + enablePromptHelpTopic: boolean +): Promise { + const server = isServerInteraction(interaction); + await server.setPromptHelpTopic(enablePromptHelpTopic); + await interaction.update( + PromptHelpTopicConfigMenu( + server, + interaction.channelId, + false, + `Successfully turned ${ + enablePromptHelpTopic ? 'on' : 'off' + } help topic prompt.` + ) + ); +} + +async function toggleSeriousMode( + interaction: ButtonInteraction<'cached'>, + enableSeriousMode: boolean +): Promise { + const server = isServerInteraction(interaction); + await server.setSeriousServer(enableSeriousMode); + await interaction.update( + SeriousModeConfigMenu( + server, + interaction.channelId, + false, + `Successfully turned ${enableSeriousMode ? 'on' : 'off'} serious mode.` + ) + ); +} + export { baseYabobButtonMethodMap }; diff --git a/src/interaction-handling/command-handler.ts b/src/interaction-handling/command-handler.ts index 120b09e4..ef6851cd 100644 --- a/src/interaction-handling/command-handler.ts +++ b/src/interaction-handling/command-handler.ts @@ -22,7 +22,7 @@ import { SettingsMainMenu } from '../attending-server/server-settings-menus.js'; import { ExpectedParseErrors } from './interaction-constants/expected-interaction-errors.js'; import { afterSessionMessageModal, - helpTopicPromptModal, + promptHelpTopicModal, queueAutoClearModal } from './interaction-constants/modal-objects.js'; import { SuccessMessages } from './interaction-constants/success-messages.js'; @@ -88,7 +88,7 @@ async function enqueue( } await server.enqueueStudent(interaction.member, queueChannel); server.promptHelpTopic - ? await interaction.showModal(helpTopicPromptModal(server.guild.id)) + ? await interaction.showModal(promptHelpTopicModal(server.guild.id)) : await interaction.editReply( SuccessMessages.joinedQueue(queueChannel.queueName) ); diff --git a/src/interaction-handling/interaction-constants/interaction-names.ts b/src/interaction-handling/interaction-constants/interaction-names.ts index 4f7344f6..10a0776b 100644 --- a/src/interaction-handling/interaction-constants/interaction-names.ts +++ b/src/interaction-handling/interaction-constants/interaction-names.ts @@ -54,7 +54,11 @@ enum ButtonNames { AutoGiveStudentRoleConfig1 = 'AutoGiveStudentRoleConfig1', AutoGiveStudentRoleConfig2 = 'AutoGiveStudentRoleConfig2', ShowAfterSessionMessageModal = 'ShowAfterSessionMessageModal', - ShowQueueAutoClearModal = 'ShowQueueAutoClearModal' + ShowQueueAutoClearModal = 'ShowQueueAutoClearModal', + PromptHelpTopicConfig1 = 'PromptHelpTopicConfig1', + PromptHelpTopicConfig2 = 'PromptHelpTopicConfig2', + SeriousModeConfig1 = 'SeriousModeConfig1', + SeriousModeConfig2 = 'SeriousModeConfig2' } /** @@ -65,7 +69,7 @@ enum ModalNames { AfterSessionMessageModalMenuVersion = 'AfterSessionMessageModalMenuVersion', QueueAutoClearModal = 'QueueAutoClearModal', QueueAutoClearModalMenuVersion = 'QueueAutoClearModalMenuVersion', - HelpTopicPromptModal = 'HelpTopicPromptModal' + PromptHelpTopicModal = 'PromptHelpTopicModal' } /** Known base yabob select menu names */ diff --git a/src/interaction-handling/interaction-constants/modal-objects.ts b/src/interaction-handling/interaction-constants/modal-objects.ts index 04f0e102..edfb5de1 100644 --- a/src/interaction-handling/interaction-constants/modal-objects.ts +++ b/src/interaction-handling/interaction-constants/modal-objects.ts @@ -101,10 +101,10 @@ function afterSessionMessageModal(serverId: Snowflake, useMenu = false): ModalBu * @param serverId * @returns */ -function helpTopicPromptModal(serverId: Snowflake): ModalBuilder { +function promptHelpTopicModal(serverId: Snowflake): ModalBuilder { const modal = buildComponent(new ModalBuilder(), [ 'other', - ModalNames.HelpTopicPromptModal, + ModalNames.PromptHelpTopicModal, serverId, UnknownId ]) @@ -122,4 +122,4 @@ function helpTopicPromptModal(serverId: Snowflake): ModalBuilder { return modal; } -export { queueAutoClearModal, afterSessionMessageModal, helpTopicPromptModal }; +export { queueAutoClearModal, afterSessionMessageModal, promptHelpTopicModal }; diff --git a/src/interaction-handling/modal-handler.ts b/src/interaction-handling/modal-handler.ts index b0253c65..158ab918 100644 --- a/src/interaction-handling/modal-handler.ts +++ b/src/interaction-handling/modal-handler.ts @@ -16,7 +16,7 @@ const baseYabobModalMap: ModalSubmitHandlerProps = { guildMethodMap: { queue: {}, other: { - [ModalNames.HelpTopicPromptModal]: interaction => + [ModalNames.PromptHelpTopicModal]: interaction => studentJoinedQueue(interaction), [ModalNames.AfterSessionMessageModal]: interaction => setAfterSessionMessage(interaction, false),