diff --git a/db/initialize.ts b/db/initialize.ts index 79157c2..c332294 100644 --- a/db/initialize.ts +++ b/db/initialize.ts @@ -66,6 +66,18 @@ await dbClient.execute(` ) ENGINE=InnoDB DEFAULT CHARSET=utf8; `); console.log('Table created'); +/** + * notifiedFlag + * 0 = Not notified + * 1 = Notified Successfully + * -1 = Failed to notify + * lockedFlag + * 0 = Not locked + * 1 = Locked Successfully + * -1 = Failed to lock + * + * If both are -1, the event failed to delete + */ await dbClient.close(); console.log('Done!'); diff --git a/src/commandUtils.ts b/src/commandUtils.ts index 53367f5..5ad3354 100644 --- a/src/commandUtils.ts +++ b/src/commandUtils.ts @@ -36,7 +36,7 @@ export const somethingWentWrong = async (bot: Bot, interaction: Interaction, err description: 'You should not be able to get here. Please try again and if the issue continues, `/report` this issue to the developers with the error code below.', fields: [{ name: 'Error Code:', - value: errorCode, + value: `\`${errorCode}\``, }], }], }, diff --git a/src/db.ts b/src/db.ts index f68d9b6..9edf91e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -17,7 +17,7 @@ export const queries = { selectFailedEvents: 'SELECT * FROM active_events WHERE (notifiedFlag = -1 OR lockedFlag = -1) AND eventTime < ?', insertEvent: 'INSERT INTO active_events(messageId,channelId,guildId,ownerId,eventTime) values(?,?,?,?,?)', updateEventTime: 'UPDATE active_events SET eventTime = ? WHERE channelId = ? AND messageId = ?', - updateEventFlags: (notifiedFlag: number, lockedFlag: number) => `UPDATE active_events SET notifiedFlag = ${notifiedFlag} AND lockedFlag = ${lockedFlag} WHERE channelId = ? AND messageId = ?`, + updateEventFlags: (notifiedFlag: number, lockedFlag: number) => `UPDATE active_events SET notifiedFlag = ${notifiedFlag}, lockedFlag = ${lockedFlag} WHERE channelId = ? AND messageId = ?`, deleteEvent: 'DELETE FROM active_events WHERE channelId = ? AND messageId = ?', }; diff --git a/src/events/ready.ts b/src/events/ready.ts index 054c339..b0cd5dd 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,12 +1,11 @@ +import { ActivityTypes, Bot, BotWithCache, log, LT } from '../../deps.ts'; import config from '../../config.ts'; import { LOCALMODE } from '../../flags.ts'; -import { ActivityTypes, Bot, BotWithCache, log, LT } from '../../deps.ts'; import { getRandomStatus, successColor } from '../commandUtils.ts'; import { ActiveEvent } from '../types/commandTypes.ts'; import utils from '../utils.ts'; import { dbClient, queries } from '../db.ts'; - -const tenMinutes = 10 * 60 * 1000; +import { deleteEvent, handleFailures, lockEvent, notifyEventMembers, tenMinutes } from '../notificationSystem.ts'; // Storing intervalIds in case bot soft reboots to prevent multiple of these intervals from stacking let notificationIntervalId: number; @@ -40,28 +39,25 @@ export const ready = (rawBot: Bot) => { // Interval to handle event notifications and cleanup every minute if (notificationIntervalId) clearInterval(notificationIntervalId); - notificationIntervalId = setInterval(() => { + notificationIntervalId = setInterval(async () => { + log(LT.LOG, 'Running notification system'); const now = new Date().getTime(); - // Notify Members of Events - dbClient.execute(queries.selectEvents(0, 0), [new Date(now + tenMinutes)]).then((events) => events.rows?.forEach((event) => console.log(event as ActiveEvent))).catch((e) => + // Get all the events + const eventsToNotify = await dbClient.execute(queries.selectEvents(0, 0), [new Date(now + tenMinutes)]).catch((e) => utils.commonLoggers.dbError('ready.ts@notifyMembers', 'SELECT events from', e) ); + const eventsToLock = await dbClient.execute(queries.selectEvents(1, 0), [new Date(now)]).catch((e) => utils.commonLoggers.dbError('ready.ts@notifyAlternates', 'SELECT events from', e)); + const eventsToDelete = await dbClient.execute(queries.selectEvents(1, 1), [new Date(now - tenMinutes)]).catch((e) => utils.commonLoggers.dbError('ready.ts@deleteEvent', 'SELECT events from', e)); + const eventFailuresToHandle = await dbClient.execute(queries.selectFailedEvents, [new Date(now + tenMinutes)]).catch((e) => + utils.commonLoggers.dbError('ready.ts@handleFailures', 'SELECT events from', e) + ); - // // Notify Alternates of Events (if NOT full) and lock the event message - // dbClient.execute(queries.selectEvents(1, 0), [new Date(now)]).then((events) => console.log(events.rows as ActiveEvent[])).catch((e) => - // utils.commonLoggers.dbError('ready.ts@notifyAlternates', 'SELECT events from', e) - // ); - - // // Delete the event - // dbClient.execute(queries.selectEvents(1, 1), [new Date(now - tenMinutes)]).then((events) => console.log(events.rows as ActiveEvent[])).catch((e) => - // utils.commonLoggers.dbError('ready.ts@deleteEvent', 'SELECT events from', e) - // ); - - // // Handle events that failed at some point - // dbClient.execute(queries.selectFailedEvents, [new Date(now + tenMinutes)]).then((events) => console.log(events.rows as ActiveEvent[])).catch((e) => - // utils.commonLoggers.dbError('ready.ts@deleteEvent', 'SELECT events from', e) - // ); + // Run all the handlers + eventsToNotify?.rows?.forEach((event) => notifyEventMembers(bot, event as ActiveEvent)); + eventsToLock?.rows?.forEach((event) => lockEvent(bot, event as ActiveEvent)); + eventsToDelete?.rows?.forEach((event) => deleteEvent(bot, event as ActiveEvent)); + eventFailuresToHandle?.rows?.forEach((event) => handleFailures(bot, event as ActiveEvent)); }, 60000); // setTimeout added to make sure the startup message does not error out diff --git a/src/notificationSystem.ts b/src/notificationSystem.ts index bf994be..01784e3 100644 --- a/src/notificationSystem.ts +++ b/src/notificationSystem.ts @@ -1,2 +1,235 @@ -// define functions that take db data and do actions on it -// in ready, call db select to create a to do list and send it here in dbclient.execute().then(FUNC FROM THIS FILE).catch() +import config from '../config.ts'; +import { Bot } from '../deps.ts'; +import { LfgEmbedIndexes } from './buttons/eventUtils.ts'; +import { getEventMemberCount, getGuildName, getLfgMembers } from './buttons/live-event/utils.ts'; +import { failColor, infoColor1, sendDirectMessage, warnColor } from './commandUtils.ts'; +import { dbClient, queries } from './db.ts'; +import { ActiveEvent } from './types/commandTypes.ts'; +import utils from './utils.ts'; + +const notifyStepName = 'notify'; +const lockStepName = 'lock'; +const deleteStepName = 'delete'; + +export const tenMinutes = 10 * 60 * 1000; + +// Join strings with english in mind +const joinWithAnd = (words: string[]) => { + if (words.length === 0) { + return ''; + } else if (words.length === 1) { + return words[0]; + } else if (words.length === 2) { + return words.join(' and '); + } else { + return words.slice(0, -1).join(', ') + ', and ' + words.slice(-1); + } +}; + +// Log the failure in a loud sense +const loudLogFailure = async (bot: Bot, event: ActiveEvent, stepName: string, secondFailure = false) => { + const guildName = await getGuildName(bot, event.guildId); + const eventUrl = utils.idsToMessageUrl({ + guildId: event.guildId, + channelId: event.channelId, + messageId: event.messageId, + }); + + // Try to DM owner if this is the second time it has failed + let dmSuccess = false; + if (secondFailure) { + const msg = await sendDirectMessage(bot, event.ownerId, { + embeds: [{ + color: failColor, + title: `Attention: Failed to ${stepName} one of your events`, + description: + `${config.name} tried twice to find [this event](${eventUrl}) in ${guildName}, and could not either time. Since ${config.name} has failed twice, ${config.name} has now removed [this event](${eventUrl}) from its list of active events. + +[This event](${eventUrl}) was scheduled to start at . + +The message containing this event may have been deleted by a moderator or administrator in ${guildName}. If [the event](${eventUrl}) still exists when you click on the link above, please \`/report\` this issue to the developers with the full error code below.`, + fields: [{ + name: 'Error Code:', + value: `\`loudLog@${event.guildId}|${event.channelId}|${event.messageId}|${event.ownerId}|${event.eventTime.getTime()}|${event.notifiedFlag}|${event.lockedFlag}@\``, + }], + }], + }).catch((e: Error) => utils.commonLoggers.messageSendError('notificationSystem.ts@loudLog', 'send DM fail', e)); + dmSuccess = Boolean(msg); + } + + // Log this to bot's log channel + bot.helpers.sendMessage(config.logChannel, { + content: secondFailure ? `Hey <@${config.owner}>, something may have gone wrong. The owner of this event was ${dmSuccess ? 'SUCCESSFULLY' : 'NOT'} notified.` : undefined, + embeds: [{ + color: secondFailure ? failColor : warnColor, + title: `Failed to ${stepName} an event in ${guildName}. This is the ${secondFailure ? 'second' : 'first'} attempt.`, + description: `${config.name} failed to ${stepName} [this event](${eventUrl}).\n\nDebug Data:`, + fields: [{ + name: 'Guild ID:', + value: `${event.guildId}`, + inline: true, + }, { + name: 'Channel ID:', + value: `${event.channelId}`, + inline: true, + }, { + name: 'Message ID:', + value: `${event.messageId}`, + inline: true, + }, { + name: 'Owner ID:', + value: `${event.ownerId}`, + inline: true, + }, { + name: 'Event Time:', + value: ``, + inline: true, + }, { + name: 'Notified Flag:', + value: `${event.notifiedFlag}`, + inline: true, + }, { + name: 'Locked Flag:', + value: `${event.lockedFlag}`, + inline: true, + }], + }], + }).catch((e: Error) => utils.commonLoggers.messageSendError('notificationSystem.ts@loudLog', 'send log message', e)); +}; + +// Notifies all members of the event and edits the event message +export const notifyEventMembers = async (bot: Bot, event: ActiveEvent, secondTry = false): Promise => { + const eventMessage = await bot.helpers.getMessage(event.channelId, event.messageId).catch((e: Error) => utils.commonLoggers.messageGetError('notificationSystem.ts@notify', 'get event', e)); + if (eventMessage?.embeds[0].fields) { + const activityName = `\`${eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].name} ${eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].value}\``; + const members = getLfgMembers(eventMessage.embeds[0].fields[LfgEmbedIndexes.JoinedMembers].value); + const memberMentionString = joinWithAnd(members.map((member) => `<@${member.id}>`)); + const guildName = await getGuildName(bot, event.guildId); + + // Edit event in guild + await bot.helpers.editMessage(event.channelId, event.messageId, { + content: `Attention ${memberMentionString}, your ${activityName} starts in less than 10 minutes.`, + }).catch((e: Error) => utils.commonLoggers.messageEditError('notificationSystem.ts@notify', 'event edit fail', e)); + + // Send the notifications to the members + members.forEach(async (member) => { + await sendDirectMessage(bot, member.id, { + embeds: [{ + color: infoColor1, + title: `Hello ${member.name}! Your activity in ${guildName} starts in less than 10 minutes.`, + description: 'Please start grouping up with the other members of this activity:', + }, eventMessage.embeds[0]], + }).catch((e: Error) => utils.commonLoggers.messageSendError('notificationSystem.ts@notify', 'send DM fail', e)); + }); + + // Update DB to indicate notifications have been sent out + dbClient.execute(queries.updateEventFlags(1, 0), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@notifySuccess', 'update event in', e)); + return true; + } else { + if (!secondTry) loudLogFailure(bot, event, notifyStepName); + // Update DB to indicate notifications have not been sent out + dbClient.execute(queries.updateEventFlags(-1, 0), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@notifyFail', 'update event in', e)); + return false; + } +}; + +// Locks the event message and notifies alternates if necessary +export const lockEvent = async (bot: Bot, event: ActiveEvent, secondTry = false): Promise => { + const eventMessage = await bot.helpers.getMessage(event.channelId, event.messageId).catch((e: Error) => utils.commonLoggers.messageGetError('notificationSystem.ts@lock', 'get event', e)); + if (eventMessage?.embeds[0].fields) { + const [currentMemberCount, maxMemberCount] = getEventMemberCount(eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].name); + const alternates = getLfgMembers(eventMessage.embeds[0].fields[LfgEmbedIndexes.AlternateMembers].value); + const memberMentionString = joinWithAnd(alternates.map((member) => `<@${member.id}>`)); + + // See if event was filled or not, and if not notify alternates + const alternatesNeeded = alternates.length && currentMemberCount < maxMemberCount; + if (alternatesNeeded) { + const activityName = `\`${eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].name} ${eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].value}\``; + const guildName = await getGuildName(bot, event.guildId); + + // Send the notifications to the members + alternates.forEach(async (member) => { + await sendDirectMessage(bot, member.id, { + embeds: [{ + color: infoColor1, + title: `Hello ${member.name}! An activity in ${guildName} may need your help.`, + description: `The ${activityName} in ${guildName} that you marked yourself as an alternate for may be \`${ + maxMemberCount - currentMemberCount + }\` people short. If you are available, please join up with them.`, + }, eventMessage.embeds[0]], + }).catch((e: Error) => utils.commonLoggers.messageSendError('notificationSystem.ts@lock', 'send DM fail', e)); + }); + } + + // Edit event in guild + await bot.helpers.editMessage( + event.channelId, + event.messageId, + alternatesNeeded + ? { + content: `${eventMessage.content}\n\nAttention ${memberMentionString}, this activity is \`${maxMemberCount - currentMemberCount}\` people short. Please join up if you are available.`, + components: [], + } + : { + components: [], + }, + ).catch((e: Error) => utils.commonLoggers.messageEditError('notificationSystem.ts@lock', 'event edit fail', e)); + + // Update DB to indicate event has been locked + dbClient.execute(queries.updateEventFlags(1, 1), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@lockSuccess', 'update event in', e)); + return true; + } else { + if (!secondTry) loudLogFailure(bot, event, lockStepName); + // Update DB to indicate event has not been locked + dbClient.execute(queries.updateEventFlags(1, -1), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@lockFail', 'update event in', e)); + return false; + } +}; + +// Notifies all members of the event and edits the event message +export const deleteEvent = async (bot: Bot, event: ActiveEvent, secondTry = false): Promise => { + const eventMessage = await bot.helpers.getMessage(event.channelId, event.messageId).catch((e: Error) => utils.commonLoggers.messageGetError('notificationSystem.ts@delete', 'get event', e)); + if (eventMessage?.embeds[0].fields) { + // Delete event in guild + await bot.helpers.deleteMessage(event.channelId, event.messageId, 'Cleaning up activity that has started').catch((e: Error) => + utils.commonLoggers.messageDeleteError('notificationSystem.ts@delete', 'event delete fail', e) + ); + + // Remove event from DB + dbClient.execute(queries.deleteEvent, [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@deleteSuccess', 'delete event from', e)); + return true; + } else { + if (!secondTry) loudLogFailure(bot, event, deleteStepName); + // Update DB to indicate delete failed + dbClient.execute(queries.updateEventFlags(-1, -1), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@deleteFail', 'update event in', e)); + return false; + } +}; + +// Handles trying again once and cleaning up events that died +export const handleFailures = async (bot: Bot, event: ActiveEvent) => { + let rerunSuccess: boolean; + let stepName: string; + // Retry the step that failed + if (event.notifiedFlag === -1 && event.lockedFlag === -1) { + rerunSuccess = await deleteEvent(bot, event, true); + stepName = deleteStepName; + } else if (event.lockedFlag === -1) { + rerunSuccess = await lockEvent(bot, event, true); + stepName = lockStepName; + } else if (event.notifiedFlag === -1) { + rerunSuccess = await notifyEventMembers(bot, event, true); + stepName = notifyStepName; + } else { + // Should never get here as this func should only be called when event has one of the flags as -1 + // Set flag to true since it already succeeded? + rerunSuccess = true; + stepName = ''; + } + + if (!rerunSuccess) { + // Failed at completing a step! Event may have been deleted? + loudLogFailure(bot, event, stepName, true); + dbClient.execute(queries.deleteEvent, [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@handleFailures', 'delete event from', e)); + } +}; diff --git a/src/utils.ts b/src/utils.ts index 713ee43..e997e08 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,24 +20,23 @@ const messageUrlToIds = (url: string): UrlIds => { const capitalizeFirstChar = (input: string) => `${input.charAt(0).toUpperCase()}${input.slice(1)}`; -const genericLogger = (level: LT, message: string) => log(level, message); const interactionSendError = (location: string, interaction: Interaction | string, err: Error) => - genericLogger(LT.ERROR, `${location} | Failed to respond to interaction: ${jsonStringifyBig(interaction)} | Error: ${err.name} - ${err.message}`); + log(LT.ERROR, `${location} | Failed to respond to interaction: ${jsonStringifyBig(interaction)} | Error: ${err.name} - ${err.message}`); const messageEditError = (location: string, message: Message | string, err: Error) => - genericLogger(LT.ERROR, `${location} | Failed to edit message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); + log(LT.ERROR, `${location} | Failed to edit message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); const messageGetError = (location: string, message: Message | string, err: Error) => - genericLogger(LT.ERROR, `${location} | Failed to get message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); + log(LT.ERROR, `${location} | Failed to get message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); const messageSendError = (location: string, message: Message | CreateMessage | string, err: Error) => - genericLogger(LT.ERROR, `${location} | Failed to send message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); + log(LT.ERROR, `${location} | Failed to send message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); const messageDeleteError = (location: string, message: Message | string, err: Error) => - genericLogger(LT.ERROR, `${location} | Failed to delete message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); + log(LT.ERROR, `${location} | Failed to delete message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); const reactionAddError = (location: string, message: Message | string, err: Error, emoji: string) => - genericLogger(LT.ERROR, `${location} | Failed to add emoji (${emoji}) to message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); + log(LT.ERROR, `${location} | Failed to add emoji (${emoji}) to message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); const reactionDeleteError = (location: string, message: Message | string, err: Error, emoji: string) => - genericLogger(LT.ERROR, `${location} | Failed to delete emoji (${emoji}) from message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); -const channelUpdateError = (location: string, message: string, err: Error) => genericLogger(LT.ERROR, `${location} | Failed to update channel | ${message} | Error: ${err.name} - ${err.message}`); + log(LT.ERROR, `${location} | Failed to delete emoji (${emoji}) from message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`); +const channelUpdateError = (location: string, message: string, err: Error) => log(LT.ERROR, `${location} | Failed to update channel | ${message} | Error: ${err.name} - ${err.message}`); -const dbError = (location: string, type: string, err: Error) => genericLogger(LT.ERROR, `${location} | Failed to ${type} database | Error: ${err.name} - ${err.message}`); +const dbError = (location: string, type: string, err: Error) => log(LT.ERROR, `${location} | Failed to ${type} database | Error: ${err.name} - ${err.message}`); export default { capitalizeFirstChar,