diff --git a/src/__mocks__/discord.js.ts b/src/__mocks__/discord.js.ts index 194202c..529d774 100644 --- a/src/__mocks__/discord.js.ts +++ b/src/__mocks__/discord.js.ts @@ -3,14 +3,25 @@ import { Client, ClientOptions, GuildManager, RESTOptions, UserFlagsBitField } f const discordJS = jest.requireActual("discord.js"); const mockDiscordJS = jest.createMockFromModule("discord.js"); +// types mockDiscordJS.MessageFlags = discordJS.MessageFlags; -mockDiscordJS.EmbedBuilder = discordJS.EmbedBuilder; mockDiscordJS.ApplicationCommandOptionType = discordJS.ApplicationCommandOptionType; mockDiscordJS.GatewayIntentBits = discordJS.GatewayIntentBits; mockDiscordJS.UserFlags = discordJS.UserFlags; mockDiscordJS.ApplicationCommandType = discordJS.ApplicationCommandType; +mockDiscordJS.ComponentType = discordJS.ComponentType; + +// classes mockDiscordJS.Collection = discordJS.Collection; +// embeds +mockDiscordJS.ActionRowBuilder = discordJS.ActionRowBuilder; +mockDiscordJS.EmbedBuilder = discordJS.EmbedBuilder; +mockDiscordJS.ButtonStyle = discordJS.ButtonStyle; +mockDiscordJS.StringSelectMenuBuilder = discordJS.StringSelectMenuBuilder; +mockDiscordJS.StringSelectMenuOptionBuilder = discordJS.StringSelectMenuOptionBuilder; +mockDiscordJS.ButtonBuilder = discordJS.ButtonBuilder; + Object.defineProperty(mockDiscordJS, "Routes", { writable: true, value: { @@ -111,9 +122,60 @@ class MockClientUser { } }; +// class MockStringSelectMenuBuilder extends discordJS.StringSelectMenuBuilder { +// constructor() { +// super(); +// } +// public setLabel(label: string): this { +// return this; +// } + +// public setDescription(description: string): this { +// return this; +// } + +// public setCustomId(customId: string): this { +// return this; +// } +// } + +// class MockStringSelectMenuOptionBuilder extends discordJS.StringSelectMenuOptionBuilder { +// constructor() { +// super(); +// } + +// public setLabel(label: string): this { +// return this; +// } + +// public setValue(value: string): this { +// return this; +// } + +// public setDescription(description: string): this { +// return this; +// } +// } + +// class MockButtonBuilder extends discordJS.ButtonBuilder { +// constructor() { +// super(); +// } + +// public setCustomId(customId: string): this { +// return this; +// } + +// public setLabel(label: string): this { +// return this; +// } + +// public setStyle(style: ButtonStyle): this { +// return this; +// } +// } -// @ts-ignore mockDiscordJS.REST = MockREST; mockDiscordJS.Client = MockClient as unknown as typeof discordJS.Client; mockDiscordJS.ClientUser = MockClientUser as unknown as typeof discordJS.ClientUser; diff --git a/src/discordApp.ts b/src/discordApp.ts index 9639c81..7e44711 100644 --- a/src/discordApp.ts +++ b/src/discordApp.ts @@ -1,6 +1,6 @@ import "dotenv/config"; import { Client, GatewayIntentBits, Events } from "discord.js"; -import { clarify, embed, ping, tone, requestAnonymousClarification, mood } from "./interactions" +import { clarify, embed, ping, tone, requestAnonymousClarification, mood, inDepthClarification, postemptiveToneAdd, getTones, action } from "./interactions" import { cleanupMoods } from "./helpers"; export async function launchBot(): Promise { @@ -57,10 +57,14 @@ export async function launchBot(): Promise { if (interaction.isChatInputCommand()) { // slash command if (interaction.commandName === "ping") await ping(interaction); if (interaction.commandName === "embed") await embed(interaction); + if (interaction.commandName === "action") await action(interaction); + if (interaction.commandName === "list-tones") await getTones(interaction); if (interaction.commandName === "mood") await mood(interaction); } else if (interaction.isMessageContextMenuCommand()) { // command from the "apps" menu when clicking on a message if (interaction.commandName === "Tone") await tone(interaction); + if (interaction.commandName === "Add Tone") await postemptiveToneAdd(interaction); if (interaction.commandName === "Clarify") await clarify(interaction); + if (interaction.commandName === "In-Depth Clarification") await inDepthClarification(interaction); if (interaction.commandName === "Request Anonymous Clarification") await requestAnonymousClarification(interaction); } else { console.log(interaction); diff --git a/src/gptRequests.ts b/src/gptRequests.ts index 113d724..6d4b5d7 100644 --- a/src/gptRequests.ts +++ b/src/gptRequests.ts @@ -57,6 +57,105 @@ export async function analyzeTone(userText: string): Promise { } } +export async function explanationOfTone(userText: string): Promise { + const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": ` + Humans are unpredictable beings. A text message by someone can be interpreted as passive aggressive + or cheerful depending on how the person reads it in their minds. This can lead to misunderstandings. + However, assume you are an expert in understanding human emotions when they send text messages. + I want you to reply to the following texts by giving your best guess about what the person might be + feeling when they wrote it. You are to figure out whether they are sad, mad, happy, neutral, or any + other emotion that the person is conveying. Assume the texter is familiar with the modern day texting conventions. + Sometimes, the text will contain "@vibecheque". You are to discard that, and only analyze + the rest of the text. + + I want you to analyze the text with the intention of clarifying the message to solve the aformentioned issues. + For any tone in the message, I want you to format your explanation as following: + + > "text" + + If the message has fragments with different tone, I want you to use the following format instead: + + > "text fragment" + + After the tone, I want you to explain how the tone applies to respective fragment in a single sentence. + After the analysis, I want you to include some variation of "Hope that clears things up!" + ` + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": userText + } + ] + } + ] + }); + + if (response.choices[0].message.content !== null){ + return response.choices[0].message.content; + } + else{ + return "Unknown error - can't explain tone at the moment" + } +} + +export async function emojiRepresentation(userText: string): Promise { + const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": ` + Humans are unpredictable beings. A text message by someone can be interpreted as passive aggressive + or cheerful depending on how the person reads it in their minds. This can lead to misunderstandings. + However, assume you are an expert in understanding human emotions when they send text messages. + I want you to reply to the following texts by giving your best guess about what the person might be + feeling when they wrote it. You are to figure out whether they are sad, mad, happy, neutral, or any + other emotion that the person is conveying. Assume the texter is familiar with the modern day texting conventions. + Sometimes, the text will contain "@vibecheque". You are to discard that, and only analyze + the rest of the text. + + I want you to tune into the tone behind any messages you receive and reply only with the emoji which best fits. + If you are unable to determine an emoji for the message, respond only with this emoji: 🛒 + ` + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": userText + } + ] + } + ] + }); + + if (response.choices[0].message.content !== null){ + return response.choices[0].message.content; + } + else{ + return "Unknown error - can't generate emojis at the moment" + } +} + // wrapper for determining the color of a given tone // TODO: avoid common background colors export async function analyzeMoodColor(mood: string): Promise { diff --git a/src/interactions.test.ts b/src/interactions.test.ts index f6b5160..99a120e 100644 --- a/src/interactions.test.ts +++ b/src/interactions.test.ts @@ -1,5 +1,13 @@ -import * as discordJS from "discord.js"; -import { embed, ping, tone, mood, clarify, requestAnonymousClarification } from "./interactions"; +import { + ChatInputCommandInteraction, + EmbedBuilder, + Message, + ActionRowBuilder, + InteractionEditReplyOptions, + MessageCollector, + Collection +} from "discord.js"; +import { embed, ping, tone, action, getTones, Tone, mood, clarify, requestAnonymousClarification } from "./interactions"; import { MockDiscord } from "./testing/mocks/mockDiscord"; import * as gptRequests from "./gptRequests"; import * as firebase from "firebase/database"; @@ -25,7 +33,7 @@ describe("Testing application commands", ()=>{ test("`ping` function defers a reply, then replies with \"pong!\" after 1000 ms if the interaction is repliable", async ()=>{ const discord = new MockDiscord({ command: "/ping" }); - const interaction = discord.getInteraction() as discordJS.ChatInputCommandInteraction; + const interaction = discord.getInteraction() as ChatInputCommandInteraction; const spyDeferReply = jest.spyOn(interaction, "deferReply"); const spyEditReply = jest.spyOn(interaction, "editReply"); @@ -45,7 +53,7 @@ describe("Testing application commands", ()=>{ test("`ping` function defers a reply, then rejects the promise after 1000 ms if the interaction is not repliable", async ()=>{ const discord = new MockDiscord({ command: "/ping" }); - const interaction = discord.createMockInteraction("/ping", discord.getGuild(), discord.getGuildMember(), false) as discordJS.ChatInputCommandInteraction; + const interaction = discord.createMockInteraction("/ping", discord.getGuild(), discord.getGuildMember(), false) as ChatInputCommandInteraction; const spyDeferReply = jest.spyOn(interaction, "deferReply"); const spyEditReply = jest.spyOn(interaction, "editReply"); const spyReject = jest.fn(); @@ -70,7 +78,7 @@ describe("Testing application commands", ()=>{ test("`embed` function defers a reply, then replies with \"Purple Embed\" and \"Green Embed\", respectively", async ()=>{ const discord = new MockDiscord({ command: "/embed" }); - const interaction = discord.getInteraction() as discordJS.ChatInputCommandInteraction; + const interaction = discord.getInteraction() as ChatInputCommandInteraction; const spyDeferReply = jest.spyOn(interaction, "deferReply"); const spyEditReply = jest.spyOn(interaction, "editReply"); @@ -78,12 +86,12 @@ describe("Testing application commands", ()=>{ expect(spyDeferReply).toHaveBeenCalled(); - const message = spyEditReply.mock.calls[0][0] as discordJS.InteractionEditReplyOptions; + const message = spyEditReply.mock.calls[0][0] as InteractionEditReplyOptions; expect(message.embeds!.length).toEqual(2); - const embed1 = (message.embeds![0] as discordJS.EmbedBuilder); - const embed2 = (message.embeds![1] as discordJS.EmbedBuilder); + const embed1 = (message.embeds![0] as EmbedBuilder); + const embed2 = (message.embeds![1] as EmbedBuilder); expect(embed1.data.title!).toMatch(/Purple Embed/); expect(embed2.data.title!).toMatch(/Green Embed/); @@ -116,6 +124,63 @@ describe("Testing application commands", ()=>{ expect(spyEditReply).not.toHaveBeenCalledWith("Something went wrong."); }); + //Best coverage I can do here, I have no clue how to implement the collector as it requires a lot of set up + test("`action` function replies with two action rows, one a menu, and the other five buttons.", async () => { + const discord = new MockDiscord({ command: "/action"}); + + const message = discord.createMockMessage({}); + + const interaction = discord.getInteraction() as ChatInputCommandInteraction; + + await action(interaction); + + let reply: any = discord.getInteractionReply() as any; + + expect(reply).toHaveProperty("components"); + expect(reply.components).toHaveProperty("length"); + expect(reply.components.length).toBe(2); + console.log(reply.components[0]); + expect(reply.components[0] instanceof ActionRowBuilder).toBeTruthy(); + expect(reply.components[1] instanceof ActionRowBuilder).toBeTruthy(); + + const actionRow1 = reply.components[0]; + const actionRow2 = reply.components[1]; + + expect(actionRow1.components[0].options[0].data.label).toMatch('Option 1 Label'); + expect(actionRow1.components[0].options[1].data.label).toMatch('Option 2 Label'); + expect(actionRow1.components[0].options[2].data.label).toMatch('Option 3 Label'); + + expect(actionRow2.components[0].data.label).toMatch('Primary Button'); + expect(actionRow2.components[1].data.label).toMatch('Secondary Button'); + expect(actionRow2.components[2].data.label).toMatch('Success Button'); + expect(actionRow2.components[3].data.label).toMatch('Danger Button'); + expect(actionRow2.components[4].data.label).toMatch('Link Button'); + }); + + //Can only test that the action row builder contains the items we expect + test("`list-tones` returns a message containing a StringSelectMenu of tones", async () => { + const discord = new MockDiscord({ command: "/list-tones"}); + + const message = discord.createMockMessage({}); + + const interaction = discord.getInteraction() as ChatInputCommandInteraction; + + await getTones(interaction); + + let reply: any = discord.getInteractionReply() as any; + + expect(reply).toHaveProperty("components"); + expect(reply.components).toHaveProperty("length"); + expect(reply.components.length).toBe(1); + expect(reply.components[0] instanceof ActionRowBuilder).toBeTruthy(); + + const actionRow = reply.components[0].components[0].options; + + const tones: Tone[] = require("./tones.json").tones; + + tones.forEach((tone: Tone, value: number) => {expect(actionRow[value].data.label).toMatch(`${tone.name}: ${tone.indicator}`)}); + }); + /** * The tone function should take a message command, defer a reply, and reply with the error message * "Something went wrong." if an error occurs while parsing tone @@ -135,7 +200,7 @@ describe("Testing application commands", ()=>{ // set up spies const spyDeferReply = jest.spyOn(interaction, "deferReply"); const spyEditReply = jest.spyOn(interaction, "editReply"); - spyEditReply.mockImplementationOnce((message: any): Promise> => { + spyEditReply.mockImplementationOnce((message: any): Promise> => { throw new Error("TEST ERROR"); }); (gptRequests.analyzeTone as jest.Mock).mockReturnValue("Unknown error - can't generate the tone at the moment") @@ -199,7 +264,7 @@ describe("Testing application commands", ()=>{ > This is a test message To help me learn, I was hoping you could clarify the tone of your message. -Here's a short list of tones: \`\` (***TODO***)`; +Here's a short list of tones, select up to five that apply:`; const multiLineMessage = discord.createMockMessage({ content: `This is a @@ -211,7 +276,7 @@ test message` > test message To help me learn, I was hoping you could clarify the tone of your message. -Here's a short list of tones: \`\` (***TODO***)`; +Here's a short list of tones, select up to five that apply:`; const singleLineInteraction = discord.createMockMessageCommand("Clarify", singleLineMessage); const multiLineInteraction = discord.createMockMessageCommand("Clarify", multiLineMessage); @@ -243,7 +308,7 @@ Here's a short list of tones: \`\` (***TODO***)`; expect(discord.getRoles().find(role => role.name === "angry")).toBeDefined(); - const interaction = discord.getInteraction() as discordJS.ChatInputCommandInteraction; + const interaction = discord.getInteraction() as ChatInputCommandInteraction; // Add required properties to interaction Object.defineProperty(interaction, 'guildId', { @@ -289,7 +354,7 @@ Here's a short list of tones: \`\` (***TODO***)`; commandOptions: { currentmood: "excited" } }); - const interaction = discord.getInteraction() as discordJS.ChatInputCommandInteraction; + const interaction = discord.getInteraction() as ChatInputCommandInteraction; // Add required properties to interaction Object.defineProperty(interaction, 'guildId', { @@ -328,7 +393,7 @@ Here's a short list of tones: \`\` (***TODO***)`; commandOptions: { currentmood: "excited" } }); - const interaction = discord.getInteraction() as discordJS.ChatInputCommandInteraction; + const interaction = discord.getInteraction() as ChatInputCommandInteraction; const error = new Error("TEST ERROR"); // Add required properties to interaction @@ -404,7 +469,7 @@ Here's a short list of tones: \`\` (***TODO***)`; const interaction = discord.createMockMessageCommand("Request Clarification", mockMessage); const spyCollector = jest.spyOn(mockMessage.author.dmChannel!, "createMessageCollector"); - const collector = new discordJS.MessageCollector(mockMessage.author.dmChannel!); + const collector = new MessageCollector(mockMessage.author.dmChannel!); spyCollector.mockReturnValue(collector); const spyCollectorOn = jest.spyOn(collector, "on"); @@ -440,7 +505,7 @@ Here's a short list of tones: \`\` (***TODO***)`; const interaction = discord.createMockMessageCommand("Request Clarification", mockMessage); const spyCollector = jest.spyOn(mockMessage.author.dmChannel!, "createMessageCollector"); - const collector = new discordJS.MessageCollector(mockMessage.author.dmChannel!); + const collector = new MessageCollector(mockMessage.author.dmChannel!); spyCollector.mockReturnValue(collector); const spyCollectorOn = jest.spyOn(collector, "on"); @@ -454,7 +519,7 @@ Here's a short list of tones: \`\` (***TODO***)`; const endCallback: Function = spyCollectorOn.mock.calls.find(call => call[0] === "end" && call[1] !== undefined)?.[1] as Function; // Call the collect callback with the test tone - await endCallback(new discordJS.Collection(), "time"); + await endCallback(new Collection(), "time"); expect(interaction.user.send).toHaveBeenCalledWith(expectedResponse); }); diff --git a/src/interactions.ts b/src/interactions.ts index c11369c..24b9e31 100644 --- a/src/interactions.ts +++ b/src/interactions.ts @@ -1,17 +1,42 @@ import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, CacheType, ChatInputCommandInteraction, + ComponentType, EmbedBuilder, MessageContextMenuCommandInteraction, MessageFlags, Role, - Snowflake + Snowflake, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder } from "discord.js"; -import { analyzeTone, analyzeMoodColor } from "./gptRequests"; +import { analyzeTone, analyzeMoodColor, emojiRepresentation, explanationOfTone } from "./gptRequests"; import db from './firebase'; // Import from your firebase.ts file import { ref, set, get, child, query, DataSnapshot } from "firebase/database"; import { addRoleToDatabase, MINIMUM_MOOD_LIFESPAN, removeRoleFromDatabase, removeRoleIfUnused} from "./helpers" +//getTones and Clarify rely on toneJSON. Implementing it in firebase would be better +//import tonesData from "./tones.json" assert { type: "json"}; +import { readFile } from 'fs/promises'; + +//console.log(tonesData.tones); + +export interface Tone { + name: string; + description: string; + indicator: string; +} + +async function initializeTones(): Promise { + let tonesData: Tone[]; + return tonesData = JSON.parse( + await readFile("./src/tones.json", "utf8") + ).tones; +} + /** * the callback to a `ping` interaction * @@ -58,6 +83,208 @@ export async function embed(interaction: ChatInputCommandInteraction) interaction.editReply({ embeds: [embed1, embed2] }); } +export async function action(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + //Builds the menu + const select = new StringSelectMenuBuilder() + .setCustomId('example') + .setPlaceholder('select an option') + .addOptions( + //Build the option: + //Has a label (setLabel), description (setDescription), value (setValue), emoji (setEmoji), and can be set to selected by default (setDefault) + new StringSelectMenuOptionBuilder() + .setLabel('Option 1 Label') + .setDescription('Option 1 Description') + .setValue('Option 1 Value'), + new StringSelectMenuOptionBuilder() + .setLabel('Option 2 Label') + .setDescription('Option 2 Description') + .setValue('Option 2 Value'), + new StringSelectMenuOptionBuilder() + .setLabel('Option 3 Label') + .setDescription('Option 3 Description') + .setValue('Option 3 Value'), + ); + + //Buttons have five different styles, primary, secondary, success, danger, and link + //Each button has a customId, label, style, link, and emoji (SKUid doesn't really apply to our project) + //A button can also be set to disabled by default + const primary = new ButtonBuilder() + .setCustomId('primary') + .setLabel('Primary Button') + .setStyle(ButtonStyle.Primary); + + const secondary = new ButtonBuilder() + .setCustomId('secondary') + .setLabel('Secondary Button') + .setStyle(ButtonStyle.Secondary); + + const success = new ButtonBuilder() + .setCustomId('success') + .setLabel('Success Button') + .setStyle(ButtonStyle.Success); + + const danger = new ButtonBuilder() + .setCustomId('danger') + .setLabel('Danger Button') + .setStyle(ButtonStyle.Danger); + + //Important! URL and CustomID are mutually exclusive + const link = new ButtonBuilder() + .setLabel('Link Button') + .setURL('https://discordjs.guide/message-components/buttons.html#button-styles') + .setStyle(ButtonStyle.Link); + + //row1 and row2 are the rows of a message, there can be up to five rows, each with five maximum elements + //Select menus have a width value of 5, while buttons have a width of 1 + const row1 = new ActionRowBuilder() + .addComponents(select); + + const row2 = new ActionRowBuilder() + .addComponents(primary, secondary, success, danger, link); + + const response = await interaction.editReply({ + content: 'An example select menu', + components: [row1, row2], + }); + + //This filter ensures that only the user who issued the command can press the buttons + const collectorFilter = (i: { user: { id: string; }; }) => i.user.id === interaction.user.id; + + //this approach works to collect only one interaction + /*try { + const confirmation = await response.awaitMessageComponent({ filter: collectorFilter, time: 60_000}); + + switch (confirmation.customId) { + case 'primary': + await confirmation.update({ content: `${interaction.user.displayName} has pressed the primary button`, components: [] }); + break; + case 'secondary': + await confirmation.update({ content: `${interaction.user.displayName} has pressed the secondary button`, components: [] }); + break; + case 'success': + await confirmation.update({ content: `${interaction.user.displayName} has pressed the success button`, components: [] }); + break; + case 'danger': + await confirmation.update({ content: `${interaction.user.displayName} has pressed the danger button`, components: [] }); + break; + } + } catch (e) { + await interaction.editReply({ content: 'No interaction for the last minute, cancelling interaction', components: [] }); + }*/ + + const collector = response.createMessageComponentCollector({ filter: collectorFilter, componentType: ComponentType.Button, time: 180_000}); + + //For each interaction with the components of componentType, do the following + collector.on('collect', async i => { + const selection = i.customId; + await i.reply(`${i.user} has selected ${selection}!\n`) + }); +} + +//This should be an application command (need to select message to add tone to) +//This kinda adds tone, but it's super lame as it's only a reaction +export async function postemptiveToneAdd(interaction: MessageContextMenuCommandInteraction): Promise { + await interaction.deferReply(); + + const tones: Tone[] = await initializeTones(); + + //copy-paste select menu for tones: + //create the tone menu which allows a user to select 1-5 tones + const toneMenu = new StringSelectMenuBuilder() + .setCustomId('tone select menu') + .setPlaceholder('Select a tone') + .setMinValues(1) + .setMaxValues(5); + + //For each tone in toneJson.tones, we create a new option for our tone menu + tones.forEach((tone: Tone) => { + toneMenu.addOptions( + new StringSelectMenuOptionBuilder() + .setValue(`${tone.name} (${tone.indicator})`) + .setLabel(`${tone.name}: ${tone.indicator}`) + .setDescription(`${tone.description}`), + ); + }); + + const row = new ActionRowBuilder() + .addComponents(toneMenu); + + //await the promise and edit the reply with the following + //row is the actionRow which hold the select menu + const response = await interaction.editReply({ + content: 'Here is a list of tones: ', + components: [row], + }); + + //creates a filter that only allows the user who sent the interaction to edit it + //Need to make sure the author is the only one who can add tones + const collectorFilter = (i: {user: {id: string}; }) => i.user.id === interaction.targetMessage.author.id; + + //creates a collector with the filter above that times out in three minutes + const collector = response.createMessageComponentCollector({ filter: collectorFilter, componentType: ComponentType.StringSelect, time: 180_000}); + + //edit the reply to say what the user selected + collector.on('collect', async i => { + const selection = i.values; + //rather than form a reply, the bot should try to edit the user's message + //TODO: make selection a string instead of a string[] using map/filter + selection.forEach(async (currentValue: string) => {interaction.targetMessage.react(await emojiRepresentation(currentValue));}); + await response.delete(); + }); +} + +//A function which creates a select menu from the elements in tones.json for users to select +//This already covers the preexisting tone add +export async function getTones(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const tones: Tone[] = await initializeTones(); + + //create the tone menu which allows a user to select 1-5 tones + const toneMenu = new StringSelectMenuBuilder() + .setCustomId('tone select menu') + .setPlaceholder('Select a tone') + .setMinValues(1) + .setMaxValues(5); + + //For each tone in toneJson.tones, we create a new option for our tone menu + tones.forEach((tone: Tone) => { + toneMenu.addOptions( + new StringSelectMenuOptionBuilder() + .setValue(`${tone.name} (${tone.indicator})`) + .setLabel(`${tone.name}: ${tone.indicator}`) + .setDescription(`${tone.description}`), + ); + }); + + const row = new ActionRowBuilder() + .addComponents(toneMenu); + + //await the promise and edit the reply with the following + //row is the actionRow which hold the select menu + const response = await interaction.editReply({ + content: 'Here is a list of tones: ', + components: [row], + }); + + //creates a filter that only allows the user who sent the interaction to edit it + const collectorFilter = (i: {user: {id: string}; }) => i.user.id === interaction.user.id; + + //creates a collector with the filter above that times out in three minutes + const collector = response.createMessageComponentCollector({ filter: collectorFilter, componentType: ComponentType.StringSelect, time: 180_000}); + + //edit the reply to say what the user selected + collector.on('collect', async i => { + const selection = i.values; + await interaction.editReply({ + content: `${interaction.user.displayName}, you have selected the following tones: ${selection}`, + components: [], + }); + }); +} + /** * the callback to a `tone` interaction * @@ -78,6 +305,19 @@ export async function tone(interaction: MessageContextMenuCommandInteraction): Promise { + //is this seriously all? + interaction.reply({ + ephemeral: true, + content: await explanationOfTone(interaction.targetMessage.content) + }); +} + /** * the callback to a `clarify` interaction * @@ -91,15 +331,53 @@ export async function clarify(interaction: MessageContextMenuCommandInteraction< content: "Thanks for pointing that out, I'll ask for you!" }) + const tones: Tone[] = await initializeTones(); + + //create the tone menu and add the options from toneJSON + const toneMenu = new StringSelectMenuBuilder() + .setCustomId('tone select menu') + .setPlaceholder('Select a tone') + .setMinValues(1) + .setMaxValues(5); + + tones.forEach((tone: Tone) => { + toneMenu.addOptions( + new StringSelectMenuOptionBuilder() + .setValue(` ${tone.name} (${tone.indicator})`) + .setLabel(`${tone.name}: ${tone.indicator}`) + .setDescription(`${tone.description}`), + ); + }); + + const row = new ActionRowBuilder() + .addComponents(toneMenu); + + // then ask the message's author for clarification if (interaction.channel?.isSendable()) { - interaction.channel.send(`Hey there, ${interaction.targetMessage.author}! It seems I wasn't able to understand the tone in one of your messages: + const request = await interaction.channel.send({ + content: `Hey there, ${interaction.targetMessage.author}! It seems I wasn't able to understand the tone in one of your messages: > ${interaction.targetMessage.content.split('\n').join("\n> ")} To help me learn, I was hoping you could clarify the tone of your message. -Here's a short list of tones: \`\` (***TODO***)`); +Here's a short list of tones, select up to five that apply:`, + components: [row]}); + + //create the filter and the collector + const collectorFilter = (i: {user: {id: string}; }) => i.user.id === interaction.targetMessage.author.id; + + const collector = request.createMessageComponentCollector({ filter: collectorFilter, componentType: ComponentType.StringSelect, time: 180_000}); + + //Get the response from the, reply to the target message the tones, and delete the request + collector.on('collect', async i => { + const selection = i.values; + await interaction.targetMessage.reply({ + content: `This message was marked with the following tones: ${selection}` + }); + await request.delete(); + }); } } diff --git a/src/registerCommands.ts b/src/registerCommands.ts index 9ff9b2a..ff6de83 100644 --- a/src/registerCommands.ts +++ b/src/registerCommands.ts @@ -50,6 +50,11 @@ updateCommands([ description: 'test bot and return "pong"', type: 1, }, + { + name: "list-tones", + description: "lists a sample of tones and their description", + type: 1, + }, { name: "mood", description: "Sets the user's current mood", @@ -72,10 +77,18 @@ updateCommands([ name: "Tone", type: 3, }, + { + name: "Add Tone", + type: 3, + }, { name: "Clarify", type: 3, }, + { + name: "In-Depth Clarification", + type: 3, + }, { name:"Request Anonymous Clarification", type: 3, diff --git a/src/testing/mocks/mockDiscord.ts b/src/testing/mocks/mockDiscord.ts index 1cfba56..0d21ef0 100644 --- a/src/testing/mocks/mockDiscord.ts +++ b/src/testing/mocks/mockDiscord.ts @@ -10,6 +10,8 @@ import { MessageCreateOptions, Channel, REST, + ComponentType, + InteractionCollector, Guild, GuildMember, CommandInteractionOptionResolver, @@ -180,7 +182,19 @@ export class MockDiscord { client: client, id: "channel-id", isSendable: jest.fn(() => true), - send: jest.fn((s: string | MessagePayload) => this.latestMessage = s) + send: jest.fn((s: string | MessagePayload | MessageCreateOptions) => { + + if (typeof s == "string") { + this.latestMessage = s; + return Promise.resolve(this.createMockMessage({content: s})); + } else if (s instanceof MessagePayload) { + this.latestMessage = s; + return Promise.resolve(this.createMockMessage({content: s.makeContent()})); + } else { + this.latestMessage = s.content!; + return Promise.resolve(this.createMockMessage(s)); + } + }) } as unknown as Channel; } @@ -212,12 +226,12 @@ export class MockDiscord { id: BigInt(1), reply: jest.fn((replyOptions: string | MessagePayload | InteractionReplyOptions) => { this.interactionReply = replyOptions; - return Promise.resolve(); + return Promise.resolve(this.createMockMessage(replyOptions as MessageCreateOptions)); }), deferReply: jest.fn(), editReply: jest.fn((reply: string | MessagePayload | InteractionEditReplyOptions) => { this.interactionReply = reply; - return Promise.resolve(); + return Promise.resolve(this.createMockMessage(reply as MessageCreateOptions)); }), isRepliable: jest.fn(() => repliable), member: mockMember, @@ -247,10 +261,20 @@ export class MockDiscord { client: this.client, author: this.user, content: "MESSAGE CONTENT", + createMessageComponentCollector: jest.fn((filter: Function, componentType: ComponentType, time: number | undefined) => {return this.createMockCollector(filter, componentType, time)}), ...options } as unknown as Message; } + public createMockCollector(filter: Function, componentType: ComponentType, time: number | undefined): InteractionCollector { + return { + filter: filter, + componentType: componentType, + time: time, + on: jest.fn((event: "collect" | "dispose" | "ignore", listener: any) => {/* Do nothing */}), + } as unknown as InteractionCollector; + } + public createMockOptions(commandOptions: {}): CommandInteractionOptionResolver{ return { options: commandOptions, diff --git a/src/tones.json b/src/tones.json new file mode 100644 index 0000000..9475764 --- /dev/null +++ b/src/tones.json @@ -0,0 +1,123 @@ +{ + "tones": [{ + "name": "Joking", + "description": "A tone used to indicate a message was a joke.", + "indicator": "\\j" + }, + { + "name": "Half-Joking", + "description": "Used to indicate a message was said in a joke-like manner, but is semi-serious.", + "indicator": "\\hj" + }, + { + "name": "Sarcastic", + "description": "A tone used to indicate a message was sarcastic or includes sarcasm.", + "indicator": "\\s" + }, + { + "name": "Genuine", + "description": "A tone used to indicate a message was made with genuine intentions.", + "indicator": "\\g" + }, + { + "name": "Serious", + "description": "A tone used to indicate a message is serious and is intended to be taken seriously.", + "indicator": "\\srs" + }, + { + "name": "Non-Serious", + "description": "A tone used to indicate a message is not serious and shouldn't be taken seriously.", + "indicator": "\\nsrs" + }, + { + "name": "Positive Connotation", + "description": "A tone used to indicate a message has a positive connotation.", + "indicator": "\\pos" + }, + { + "name": "Neutral Connotation", + "description": "A tone indicating a message has a neutral connotation.", + "indicator": "\\neu" + }, + { + "name": "Negative Connotation", + "description": "A tone used to indicate a message has a negative connotation.", + "indicator": "\\neg" + }, + { + "name": "Platonic", + "description": "A tone used when a message is meant to be taken platonically", + "indicator": "\\p" + }, + { + "name": "Romantic", + "description": "A tone used when a message is meant to be taken romantically.", + "indicator": "\\r" + }, + { + "name": "Light-Hearted", + "description": "A tone used to indicate a message is light-hearted, carefree, or cheerful.", + "indicator": "\\lh" + }, + { + "name": "Little Upset", + "description": "A tone used when the sender wants to indicate they're a little upset.", + "indicator": "\\lu" + }, + { + "name": "Nobody Here", + "description": "A tone used when someone wants to indicate their post isn't directed at any recipient.", + "indicator": "\\nbh" + }, + { + "name": "Rhetorical", + "description": "A tone used when a question is rhetorical and not meant to be answered.", + "indicator": "\\rh" + }, + { + "name": "Teasing", + "description": "A tone used when a user intends to tease.", + "indicator": "\\t" + }, + { + "name": "Inside Joke", + "description": "A tone used to mark any inside joke that other recipients might not get.", + "indicator": "\\ij" + }, + { + "name": "Metaphorical", + "description": "A tone used to mark a message as metaphorical.", + "indicator": "\\m" + }, + { + "name": "Literal", + "description": "A tone used to indicate a message is literal.", + "indicator": "\\li" + }, + { + "name": "Hyperbole", + "description": "A tone used to indicate a message is hyperbole (an exaggeration)", + "indicator": "\\hyb" + }, + { + "name": "Fake", + "description": "A tone used to mark something as fake or a lie.", + "indicator": "\\f" + }, + { + "name": "Not Mad", + "description": "A tone used when a sender wants to indicate they're not mad.", + "indicator": "\\nm" + }, + { + "name": "Copypasta", + "description": "A tone used to indicate a message is a copypasta (usually humorous copy-pasted texts).", + "indicator": "\\c" + }, + { + "name": "Lyrics", + "description": "A tone used to indicate a message contains lyrics to a song.", + "indicator": "\\l" + }] + +} \ No newline at end of file