diff --git a/exceptions/ItemNotFoundException.ts b/exceptions/ItemNotFoundException.ts new file mode 100644 index 0000000..5880662 --- /dev/null +++ b/exceptions/ItemNotFoundException.ts @@ -0,0 +1,8 @@ +export class ItemNotFoundException extends Error { + constructor(m: string) { + super(m); + + // Set the prototype explicitly. + Object.setPrototypeOf(this, ItemNotFoundException.prototype); + } +} diff --git a/index.ts b/index.ts index 19f3ca4..8435dc6 100644 --- a/index.ts +++ b/index.ts @@ -11,13 +11,18 @@ import { ApplicationQuestions } from './services/constants/application-questions import { createApplicationChannel, sendQuestions } from './services/ApplicationService'; import { formatDiscordUserTag, parseServerCommand } from './services/MessageHelpers'; import { connect } from './services/DataService'; -import { extractMessageInformationAndProcessPoints, PointsAction, reactWithBasePoints } from './services/DropSubmissionService'; +import { + extractMessageInformationAndProcessPoints, + PointsAction, + processDinkPost, + processNonembedDinkPost, + reactWithBasePoints +} from './services/DropSubmissionService'; import path from 'path'; import fs from 'fs'; import { fetchPointsData } from './services/GoogleApiService'; -import { getUser, modifyNicknamePoints, modifyPoints } from './services/UserService'; -import { PointType } from './models/PointAudit'; import { schedule } from 'node-cron'; +import { ItemNotFoundException } from './exceptions/ItemNotFoundException'; dotenv.config(); @@ -68,12 +73,13 @@ dotenv.config(); schedulePointsSheetRefresh(); const pointsSheetLookup: Record = Object.fromEntries(pointsSheet ?? []); - // console.log(pointsSheetLookup); + await client.login(process.env.TOKEN); await connect(); client.once('ready', async () => { + // schedule cronjobs const server = client.guilds.cache.find((guild) => guild.id === serverId); await server?.members.fetch(); console.log('ready'); @@ -108,7 +114,7 @@ dotenv.config(); return; } - // handle forwarding drop submissions to private channel + // handle forwarding drop submissions to private channel for manual point assignment if ( (message.channel.id === process.env.PUBLIC_SUBMISSIONS_CHANNEL_ID || message.channel.id === process.env.HIGH_VALUE_PUBLIC_SUBMISSIONS_CHANNEL_ID) && @@ -127,69 +133,31 @@ dotenv.config(); message.channel.id === process.env.DISCORD_WEBHOOK_DROPS_CHANNEL_ID && message.author.id === process.env.DISCORD_WEBHOOK_DROPS_USER_ID ) { - // TODO abstract this to a service + // handle webhook automated points if (message.embeds[0]) { - const debugChannel = client.channels.cache.get(process.env.RARE_DROP_DEBUG_DUMP_CHANNEL_ID ?? ''); - const embed = message.embeds[0]; - const embedDescription = embed.description; - const item = - embedDescription?.trim() == 'Just got a pet.' || - embedDescription?.trim() == "Would've gotten a pet, but already has it." - ? 'pet' - : embed.description?.match(/\[(.*?)\]/); - const user = embed.author?.name; - if (user) { - // check that it matches the nickname scheme in the server to only ever match one ie Nickname [ - const allUsers = await message?.guild?.members.fetch(); - const possibleUser = allUsers?.find((x) => - (x.nickname ?? '').toLocaleLowerCase().startsWith(`${user.toLocaleLowerCase()}`) - ); - if (item && possibleUser) { - if (message.channel.type === ChannelType.GuildText) { - const strippedMatchItemName = item === 'pet' ? item : item[0].replace(/\[|\]/g, '').toLocaleLowerCase(); - // fuzzy match - // TODO idk if this is right - // const foundItemPointValue = pointsEntries.find(([key]) => key.includes(strippedMatchItemName))?.[1]; - const foundItemPointValue = pointsSheetLookup[strippedMatchItemName]; - if (foundItemPointValue) { - const dbUser = await getUser(possibleUser.id); - const newPoints = await modifyPoints( - dbUser, - parseInt(foundItemPointValue, 10), - PointsAction.ADD, - message.author.id, - PointType.AUTOMATED, - message.id - ); - if (newPoints) { - await modifyNicknamePoints(newPoints, possibleUser); - await message.channel.send( - `${strippedMatchItemName} is ${foundItemPointValue} points. <@${possibleUser.id}> now has ${newPoints} points.` - ); - } - } else { - console.info(`No item matching ${strippedMatchItemName}`); - if (debugChannel && debugChannel?.type === ChannelType.GuildText) { - await debugChannel.send( - `No item matching ${strippedMatchItemName}. Points not given to ${formatDiscordUserTag( - possibleUser.id - )}. Please manually check ${message.url}` - ); - } - } - } - } else { - if (debugChannel && debugChannel?.type === ChannelType.GuildText) { - await debugChannel.send(`No user found in discord matching in game name: ${user}.`); + try { + await processDinkPost(message, pointsSheetLookup); + } catch (e) { + if (e instanceof ItemNotFoundException) { + const debugChannel = client.channels.cache.get(process.env.RARE_DROP_DEBUG_DUMP_CHANNEL_ID ?? ''); + if (debugChannel && debugChannel.type === ChannelType.GuildText) { + await debugChannel.send(e.message); } } - } else { - console.error('no user found on embed'); } } else { - console.log(message.content); - console.log(`No message content for embed: ${message}`); + try { + await processNonembedDinkPost(message, pointsSheetLookup); + } catch (e) { + if (e instanceof ItemNotFoundException) { + const debugChannel = client.channels.cache.get(process.env.RARE_DROP_DEBUG_DUMP_CHANNEL_ID ?? ''); + if (debugChannel && debugChannel.type === ChannelType.GuildText) { + await debugChannel.send(e.message); + } + } + } } + // handle clan application in private channel } else { // TODO, can i make this a slash command if (message.channel.type === ChannelType.GuildText && message.channel.topic === 'application') { @@ -215,8 +183,8 @@ dotenv.config(); if (user.id === client.user?.id) { return; } + // handle emoji reactions for manual point processing if (reaction.message.channel.id === process.env.PRIVATE_SUBMISSIONS_CHANNEL_ID) { - console.log('am am about to process'); const server = client.guilds.cache.find((guild) => guild.id === serverId); await extractMessageInformationAndProcessPoints( reaction, @@ -228,6 +196,7 @@ dotenv.config(); ); console.log('am am about to process'); } + // handle creating the private application message when new users click the check in the welcome channel if (reaction.message.channel.id === process.env.INTRO_CHANNEL_ID) { const emoji = '✅'; if (reaction.emoji.name === emoji) { @@ -254,6 +223,7 @@ dotenv.config(); if (user.id === client.user?.id) { return; } + // if a reaction is removed for manual points, make sure to subtract those points from the user if (reaction.message.channel.id === process.env.PRIVATE_SUBMISSIONS_CHANNEL_ID) { const server = client.guilds.cache.find((guild) => guild.id === serverId); await extractMessageInformationAndProcessPoints( @@ -267,6 +237,7 @@ dotenv.config(); client.on('guildMemberRemove', async (member) => { if (process.env.REPORTING_CHANNEL_ID) { + // inform moderators when a member leaves the server and when their nickname in the server was const discordUser = await client.users.fetch(member.id); const reportingChannel = client.channels.cache.get(process.env.REPORTING_CHANNEL_ID); if (reportingChannel && reportingChannel.type === ChannelType.GuildText) { @@ -284,6 +255,7 @@ dotenv.config(); } }); + // handle all slash commands (logic in ./commands/ client.on(Events.InteractionCreate, async (interaction) => { if (!interaction.isChatInputCommand()) return; const command = interaction.client.commands.get(interaction.commandName); diff --git a/services/DropSubmissionService.ts b/services/DropSubmissionService.ts index f9852bc..574d07c 100644 --- a/services/DropSubmissionService.ts +++ b/services/DropSubmissionService.ts @@ -2,6 +2,8 @@ import { Channel, ChannelType, Emoji, Guild, Message, MessageReaction, PartialMe import { getUser, modifyNicknamePoints, modifyPoints } from './UserService'; import { NicknameLengthException } from '../exceptions/NicknameLengthException'; import { PointType } from '../models/PointAudit'; +import { formatDiscordUserTag } from './MessageHelpers'; +import { ItemNotFoundException } from '../exceptions/ItemNotFoundException'; const NumberEmojis = { ONE: '1️⃣', @@ -145,3 +147,176 @@ export const reactWithBasePoints = async (message: Message) => { // await message.react(NumberEmojis.NINE); await message.react(NumberEmojis.TEN); }; + +export const processNonembedDinkPost = async (message: Message, pointsSheetLookup: Record) => { + // Handle submissions that are not embeded + console.debug(message.content); + + if (message.channel.type !== ChannelType.GuildText) { + console.error("The message was not posted in the correct channel: ", message.channel.type); + return; + } + + const matches = message.content.match(/\*\*([\s\S]*?)\*\*/g); + console.debug("Matches: ", matches); + + if (matches === null) { + console.error("No bold text found in the message content: ", message.content); + return; + } + + const pieces = matches.map(part => part.replace(/\*/g, '')).filter(part => (part.length > 0 && !part.includes('*'))); + console.debug("Pieces: ", pieces); + + if (pieces.length === 1 && message.content.includes("** just got a pet!")) { + // Add "1 x " & " ()" so it matches the expected input later + pieces[1] = "1 x Pet ()"; + // Could add a lookup here to get the source of the pet + // The "Pet" section of dink does not have the %SOURCE% parameter available + pieces[2] = "unknown"; + } else if (pieces.length !== 3) { + console.error("The message did not return 3 pieces: ", pieces); + return; + } + + const player = pieces[0]; + const loot = pieces[1].split("\n").map(piece => ({ + 'item': piece.substring(piece.indexOf(" ", 2) + 1, piece.lastIndexOf(" ")), + 'quantity': parseInt(piece.substring(0, piece.indexOf(" ", 1))) + })); + + const allUsers = await message?.guild?.members.fetch(); + const possibleUser = allUsers?.find((x) => (x.nickname ?? '').toLocaleLowerCase().startsWith(player.toLocaleLowerCase())); + + if (!possibleUser) { + console.error("No possible user found: ", possibleUser); + return; + } + + if (!loot.length) { + console.error("No loot found: ", loot); + return; + } + + const validLoot = loot + .filter(x => x.item.toLocaleLowerCase() in pointsSheetLookup) + .map((itemWorthPoints): {name: string; points: number; quantity: number} => ({ + name: itemWorthPoints.item, + points: parseInt(pointsSheetLookup[itemWorthPoints.item.toLocaleLowerCase()], 10), + quantity: itemWorthPoints.quantity + })); + + const totalPointsToAdd = validLoot.reduce((total, item) => total + (item.points * item.quantity), 0); + + if (validLoot.length === 0) { + console.error("No items are worth points: ", validLoot); + return; + } + + const db_user = await getUser(possibleUser.id); + + const new_points = await modifyPoints( + db_user, + totalPointsToAdd, + PointsAction.ADD, + message.author.id, + PointType.AUTOMATED, + message.id + ); + + if (new_points === null) { + console.error("Could not modify points: ", validLoot, db_user, new_points); + return; + } + + await modifyNicknamePoints(new_points, possibleUser); + + let formattedConfirmationString = ''; + validLoot.forEach((x) => { + formattedConfirmationString += `**${x.quantity} x ${x.name}** is **${x.points * x.quantity} points**. <@${possibleUser.id}> now has **${new_points} points**\n`; + }); + + await message.channel.send( + formattedConfirmationString || + `new points: ${new_points}, but for some reason i can't tell you the formatted string...` + ); +} + +/** + * picks apart an embed from the Dink bot, looks up the number of points, and then gives them to the user + * @param message the Discord.Message that was sent and contains the embed + * @param pointsSheetLookup A Record containing {itemName: pointValue} + */ +export const processDinkPost = async (message: Message, pointsSheetLookup: Record) => { + const embed = message.embeds[0]; + const embedDescription = embed.description; + console.log(embed); + const item = + embedDescription?.trim().includes("has a funny feeling like they're being followed") || + embedDescription?.trim() == "Would've gotten a pet, but already has it." + ? 'pet' + : embed.description + ?.match(/\[(.*?)\]/g) + ?.map((str) => str.slice(1, -1)) + .slice(0, -1); // removes the []. splice removes the item source + const user = embed.author?.name; + if (user) { + // check that it matches the nickname scheme in the server to only ever match one ie Nickname [ + const allUsers = await message?.guild?.members.fetch(); + const possibleUser = allUsers?.find((x) => (x.nickname ?? '').toLocaleLowerCase().startsWith(`${user.toLocaleLowerCase()}`)); + // if i have an item and i found a user in the discord server + if (item && possibleUser) { + if (message.channel.type === ChannelType.GuildText) { + const allItemsStripped = item === 'pet' ? [item] : item.map((x) => x.replace(/\[|\]/g, '').toLocaleLowerCase()); + let foundLookup: Array<{ name: string; points: string }> = []; + for (const itemName of allItemsStripped) { + const foundItem = pointsSheetLookup[itemName]; + if (foundItem) { + foundLookup.push({ + name: itemName, + points: foundItem + }); + } + } + + // i found an item, now modify the backing user points + if (foundLookup.length) { + const dbUser = await getUser(possibleUser.id); + const totalPoints = foundLookup.reduce((acc, cur) => acc + parseInt(cur.points), 0); + const newPoints = await modifyPoints( + dbUser, + totalPoints, + PointsAction.ADD, + message.author.id, + PointType.AUTOMATED, + message.id + ); + if (newPoints) { + await modifyNicknamePoints(newPoints, possibleUser); + let formattedConfirmationString = ''; + foundLookup.forEach((x) => { + formattedConfirmationString += `${x.name} is ${x.points} points. <@${possibleUser.id}> now has ${newPoints} points.\n`; + }); + await message.channel.send( + formattedConfirmationString || + `new points: ${newPoints}, but for some reason i can't tell you the formatted string...` + ); + } + } else { + console.info(`No item matching ${allItemsStripped.toString()}`); + throw new ItemNotFoundException( + `No item matching ${allItemsStripped.toString()}. Points not given to ${formatDiscordUserTag( + possibleUser.id + )}. Please manually check ${message.url}` + ); + } + } + } else { + console.log(`No user found in discord matching in game name: ${user}.`); + throw new ItemNotFoundException(`No user found in discord matching in game name: ${user}.`); + } + } else { + console.error('no user found on embed'); + throw new ItemNotFoundException('no user found on embed'); + } +};