From cb2ae4d4f26f80d55ce1ec6a75f61861997e1817 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Mon, 8 May 2023 07:12:08 +0200 Subject: [PATCH 1/6] Fix always false if statement --- src/execution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/execution.ts b/src/execution.ts index 3b4c3f1..ca0f303 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -138,7 +138,7 @@ async function executeFromQueue(channel: string) { const answerContent = answer.data.choices[0].message?.content; - if (answerContent != undefined && answerContent != "") { + if (answerContent != undefined || answerContent != "") { const response = message.reply({ content: answerContent, allowedMentions: { From f6ac5281e79f4c741f2fe261153ba18ddeb0282a Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Mon, 8 May 2023 08:50:23 +0200 Subject: [PATCH 2/6] Prepare more execution.ts for interactions --- src/execution.ts | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/execution.ts b/src/execution.ts index ca0f303..434a76c 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -5,16 +5,20 @@ import Moderation from "./moderation"; import config from "./config"; import toOpenAIMessages from "./toOpenAIMessages"; +type NonNullableInObject = { [k in keyof T]: k extends V ? NonNullable : T[k] }; +type apiRequest = DiscordApi.Message | DiscordApi.RepliableInteraction; +export type request = apiRequest & NonNullableInObject; + /** Stores the queue requests on the channels. */ -const channelsRunning: DiscordApi.Collection = new DiscordApi.Collection(); +const channelsRunning: DiscordApi.Collection = new DiscordApi.Collection(); /** * Gets the user that requested the execution * @param request The request to get the user from * @returns the user or guild member */ -export function getAuthor(request: DiscordApi.Message | DiscordApi.Interaction) { - if (request instanceof DiscordApi.Message) return request.member ?? request.author; +export function getAuthor(request: apiRequest) { + if (request instanceof DiscordApi.Message) return request.author; return request.user; } @@ -54,9 +58,11 @@ async function getUserLimit(user: string | {id: string}, requestTimestamp: Date) * Check and queues up the request and runs it if there is nothing in queue. * @param request the message to check and queue */ -export async function queueRequest(request: DiscordApi.Message | DiscordApi.Interaction) { +export async function queueRequest(request: apiRequest) { if (!request.channelId) { - if (!(request instanceof DiscordApi.Message) && request.isRepliable()) + if (request instanceof DiscordApi.Message) + request.reply("request does not have channelId"); + else if (request.isRepliable()) request.reply("request does not have channelId"); console.log("There was incoming execution without channelId set, ignoring"); console.log(request); @@ -90,7 +96,7 @@ export async function queueRequest(request: DiscordApi.Message | DiscordApi.Inte () => { return []; }, ); const shouldStart = messagesForChannel.length == 0; - messagesForChannel.push(request); + messagesForChannel.push(request as request); if (shouldStart) executeFromQueue(request.channelId); } @@ -100,8 +106,8 @@ export async function queueRequest(request: DiscordApi.Message | DiscordApi.Inte * @param channel the channel to run the queue for */ async function executeFromQueue(channel: string) { - const channelQueue = channelsRunning.get(channel) as DiscordApi.Message[]; - const message = channelQueue.at(0) as DiscordApi.Message; + const channelQueue = channelsRunning.get(channel) as NonNullable>; + const message = channelQueue.at(0) as NonNullable>; try { let messages: DiscordApi.Collection = await message.channel.messages.fetch({ limit: config.limits.messages, cache: false }); @@ -110,7 +116,13 @@ async function executeFromQueue(channel: string) { messages.forEach(m => Moderation.checkMessage(m)); - message.channel.sendTyping(); + if (message instanceof DiscordApi.Message) { + message.channel.sendTyping(); + } + else if (message.isRepliable()) { + message.deferReply(); + } + const answer = await openai.createChatCompletion({ ...config.chatCompletionConfig, messages: toOpenAIMessages(messages), @@ -118,13 +130,13 @@ async function executeFromQueue(channel: string) { const usage = answer.data.usage; if (usage != undefined) { - const channelName: string = message.inGuild() ? `${message.channel.name} (${message.guild.name})` : `@${message.author.tag}`; - console.log(`Used ${usage.total_tokens} (${usage.prompt_tokens} + ${usage.completion_tokens}) tokens for ${message.author.tag} (${message.author.id}) in #${channelName}`); + const channelName: string = !message.channel.isDMBased() ? `${message.channel.name} (${message.guild?.name})` : `@${getAuthor(message).tag}`; + console.log(`Used ${usage.total_tokens} (${usage.prompt_tokens} + ${usage.completion_tokens}) tokens for ${getAuthor(message).tag} (${getAuthor(message).id}) in #${channelName}`); database.usage.create({ data: { timestamp: message.createdAt, - user: BigInt(message.author.id), + user: BigInt(getAuthor(message).id), channel: BigInt(message.channelId), guild: message.guildId ? BigInt(message.guildId) : null, usageReguest: usage.prompt_tokens, @@ -149,7 +161,7 @@ async function executeFromQueue(channel: string) { Moderation.checkMessage(await response); } else { - message.react("😶"); + if (message instanceof DiscordApi.Message) message.react("😶"); } } catch (e) { console.error(`Error ocurred while handling chat completion request (${(e as object).constructor.name}):`); From 28dce0b29fac9c6de532eb1d90267a030a5b0e1f Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Mon, 8 May 2023 08:50:59 +0200 Subject: [PATCH 3/6] Add support for interactions in moderation --- src/moderation.ts | 30 +++++++++++++++++++----------- src/toOpenAIMessages.ts | 18 +++++++++++++++++- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/moderation.ts b/src/moderation.ts index 02b2540..64291c8 100644 --- a/src/moderation.ts +++ b/src/moderation.ts @@ -1,31 +1,39 @@ -import { Collection, Message } from "discord.js"; +import { Collection, InteractionResponse, Message } from "discord.js"; import { openai } from "./index"; -import { formatMessage } from "./toOpenAIMessages"; +import { formatRequestOrResponse } from "./toOpenAIMessages"; export default class Moderation { /** Represents cache of messages that have been checked aganist OpenAI moderation API. */ private static cache = new Collection(); - public static async checkMessage(message: Message): Promise { + public static async checkMessage(message: Message | InteractionResponse): Promise { if (this.cache.has(message.id)) { return this.cache.get(message.id) as boolean; } - const warningReaction = message.reactions.resolve("⚠"); - // if bot reacted to that message, we already know that it returned true for moderation API - if (warningReaction && warningReaction.me) { - this.cache.set(message.id, true); - return true; + if (message instanceof Message) { + const warningReaction = message.reactions.resolve("⚠"); + // if bot reacted to that message, we already know that it returned true for moderation API + if (warningReaction && warningReaction.me) { + this.cache.set(message.id, true); + return true; + } } - try{ + try { const answer = await openai.createModeration({ - input: formatMessage(message), + input: await formatRequestOrResponse(message), }); const flagged = answer.data.results[0].flagged; this.cache.set(message.id, flagged); - if (flagged) message.react("⚠"); + if (flagged) if (message instanceof Message) { + message.react("⚠"); + } + else { + const channelMessage = await message.fetch(); + channelMessage.react("⚠"); + } return flagged; } diff --git a/src/toOpenAIMessages.ts b/src/toOpenAIMessages.ts index 54e2ba4..f81c32f 100644 --- a/src/toOpenAIMessages.ts +++ b/src/toOpenAIMessages.ts @@ -1,9 +1,25 @@ import { ChatCompletionRequestMessage as OpenAIMessage } from "openai"; -import { Collection, Message as DiscordMessage } from "discord.js"; +import { Collection, Message as DiscordMessage, InteractionResponse } from "discord.js"; import FoldToAscii from "fold-to-ascii"; import config from "./config"; import countTokens from "./tokenCounter"; +import { request } from "./execution"; + +/** + * formats the request to use as a message contend in OpenAI api + * @param request the request to format + * @returns the formatted request + */ +export async function formatRequestOrResponse(request: request | InteractionResponse): Promise { + if (request instanceof DiscordMessage) { + return formatMessage(request); + } + if (request instanceof InteractionResponse) { + return formatMessage(await request.fetch()); + } + return formatMessage(await request.fetchReply()); +} /** * Formats the message to use as a message content in OpenAI api From 56a0e686b0f302e12d17c2278e0eb95972ebc77c Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Mon, 8 May 2023 08:51:30 +0200 Subject: [PATCH 4/6] fully prepare execution for interactions --- src/execution.ts | 56 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src/execution.ts b/src/execution.ts index 434a76c..e55e9b5 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -54,6 +54,32 @@ async function getUserLimit(user: string | {id: string}, requestTimestamp: Date) return {limit: userLimits.limit, remaining: userLimits.limit - usedLimit}; } +/** + * Replies to a request + * @param request the request to reply to + * @param message the message + * @param replyOptions additional options if the request is a message + * @param interactionOptions additional options if the request is an interaction + * @returns Promise of the done action + */ +function requestReply( + request: request, + message: DiscordApi.MessageReplyOptions & DiscordApi.InteractionReplyOptions, + // TODO: add support for these below + replyOptions: DiscordApi.MessageReplyOptions = {}, + interactionOptions: DiscordApi.InteractionReplyOptions = {}, +) { + if (request instanceof DiscordApi.Message) { + return request.reply(Object.assign(message, replyOptions)); + } + else { + if (!request.deferred) + return request.reply(Object.assign(message, interactionOptions)); + else + return request.editReply(Object.assign(message, interactionOptions)); + } +} + /** * Check and queues up the request and runs it if there is nothing in queue. * @param request the message to check and queue @@ -151,14 +177,10 @@ async function executeFromQueue(channel: string) { const answerContent = answer.data.choices[0].message?.content; if (answerContent != undefined || answerContent != "") { - const response = message.reply({ - content: answerContent, - allowedMentions: { - repliedUser: false, - } - }); - Moderation.checkMessage(await response); + const response = requestReply(message, {content: answerContent}, {allowedMentions: { repliedUser: false }}); + + response.then(rval => Moderation.checkMessage(rval)); } else { if (message instanceof DiscordApi.Message) message.react("😶"); @@ -167,15 +189,17 @@ async function executeFromQueue(channel: string) { console.error(`Error ocurred while handling chat completion request (${(e as object).constructor.name}):`); console.error(e); - message.reply({ - embeds: [{ - color: 0xff0000, - description: "Something bad happened! :frowning:" - }], - allowedMentions: { - repliedUser: false, - } - }); + requestReply( + message, + { + embeds: [{ + color: 0xff0000, + description: "Something bad happened! :frowning:" + }], + }, + {allowedMentions: { repliedUser: false } }, + { ephemeral: true }, + ); } channelQueue.shift(); From 8b4b35454b4670dd259636a5921cfca144189975 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Mon, 8 May 2023 08:53:06 +0200 Subject: [PATCH 5/6] Add commandManager and the first slash command the command allows for summining the bot without sending an actual mention message that might hang in the chat log sent to openAi, consuming tokens --- package-lock.json | 21 +++++++++++++++++++- package.json | 4 +++- src/command.ts | 41 +++++++++++++++++++++++++++++++++++++++ src/commands/summon.ts | 16 +++++++++++++++ src/index.ts | 4 ++++ src/interactionManager.ts | 40 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/command.ts create mode 100644 src/commands/summon.ts create mode 100644 src/interactionManager.ts diff --git a/package-lock.json b/package-lock.json index 8bd83db..c1c7dff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "discord.js": "^14.8.0", "fold-to-ascii": "^5.0.1", "gpt-3-encoder": "^1.1.4", - "openai": "^3.2.1" + "openai": "^3.2.1", + "require-directory": "^2.1.1" }, "devDependencies": { "@types/fold-to-ascii": "^5.0.0", + "@types/require-directory": "^2.1.2", "@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/parser": "^5.55.0", "eslint": "^8.36.0", @@ -295,6 +297,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.2.tgz", "integrity": "sha512-sDPHm2wfx2QhrMDK0pOt2J4KLJMAcerqWNvnED0itPRJWvI+bK+uNHzcH1dFsBlf7G3u8tqXmRF3wkvL9yUwMw==" }, + "node_modules/@types/require-directory": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/require-directory/-/require-directory-2.1.2.tgz", + "integrity": "sha512-FUG5PJ2rsV2TssSspVZefTR8+wH3Ahr6KdAB6WanLNroSDu0A5ew4WVUxnnGU/E1k6nzip9ZawGAAe/ZqzGn5g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -1710,6 +1721,14 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/package.json b/package.json index aba236c..fc0484d 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "discord.js": "^14.8.0", "fold-to-ascii": "^5.0.1", "gpt-3-encoder": "^1.1.4", - "openai": "^3.2.1" + "openai": "^3.2.1", + "require-directory": "^2.1.1" }, "devDependencies": { "@types/fold-to-ascii": "^5.0.0", + "@types/require-directory": "^2.1.2", "@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/parser": "^5.55.0", "eslint": "^8.36.0", diff --git a/src/command.ts b/src/command.ts new file mode 100644 index 0000000..4ef8a64 --- /dev/null +++ b/src/command.ts @@ -0,0 +1,41 @@ +import { PermissionsBitField } from "discord.js"; +import { RESTPostAPIApplicationCommandsJSONBody } from "discord.js"; +import { ApplicationCommandOption, ApplicationCommandType, ChatInputCommandInteraction, LocalizationMap, MessageInteraction, PermissionResolvable, UserSelectMenuInteraction } from "discord.js"; + +type InteractionTypeMap = { + [ApplicationCommandType.ChatInput]: [ChatInputCommandInteraction, string]; + [ApplicationCommandType.Message]: [MessageInteraction, never]; + [ApplicationCommandType.User]: [UserSelectMenuInteraction, never]; +}; + +interface Command { + readonly name: string; + readonly name_localizations?: LocalizationMap; + readonly description: InteractionTypeMap[Type][1]; + readonly description_localizations?: LocalizationMap; + readonly options?: ApplicationCommandOption[]; + readonly default_member_permissions?: PermissionResolvable; + readonly type: Type; + readonly nsfw?: boolean; + readonly dm_permission?: boolean; +} + +abstract class Command { + abstract execute(interaction: InteractionTypeMap[Type][0]): Promise; + + toRESTPostApplicationCommands(): RESTPostAPIApplicationCommandsJSONBody { + return { + name: this.name, + name_localizations: this.name_localizations, + description: this.description, + description_localizations: this.description_localizations, + options: this.options, + default_member_permissions: this.default_member_permissions !== undefined ? new PermissionsBitField(this.default_member_permissions).bitfield.toString() : undefined, + type: this.type, + nsfw: this.nsfw, + dm_permission: this.dm_permission, + }; + } +} + +export default Command; diff --git a/src/commands/summon.ts b/src/commands/summon.ts new file mode 100644 index 0000000..0ce7407 --- /dev/null +++ b/src/commands/summon.ts @@ -0,0 +1,16 @@ +import { ApplicationCommandType, ChatInputCommandInteraction } from "discord.js"; + +import Command from "../command"; +import { queueRequest } from "../execution"; + + +export default class Summon extends Command { + name = "summon"; + description = "Summons a bot to reply in chat without sending any message"; + type = ApplicationCommandType.ChatInput; + dm_permission = false; + + async execute(interaction: ChatInputCommandInteraction) { + queueRequest(interaction); + } +} diff --git a/src/index.ts b/src/index.ts index cba9b7b..35f01ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { PrismaClient } from "@prisma/client"; import config from "./config"; import { queueRequest } from "./execution"; +import InteractionManager from "./interactionManager"; const discord = new DiscordApi.Client({ intents: [ @@ -19,6 +20,9 @@ export const openai = new OpenAIApi(new OpenAIApiConfiguration({ export const database = new PrismaClient(); +const interactionManager = new InteractionManager(); +interactionManager.bindClient(discord); + discord.on("ready", async event => { console.log(`Connected to Discord as ${event.user.tag} (${event.user.id})`); }); diff --git a/src/interactionManager.ts b/src/interactionManager.ts new file mode 100644 index 0000000..3b7968b --- /dev/null +++ b/src/interactionManager.ts @@ -0,0 +1,40 @@ +import { Interaction, Client as DiscordClient } from "discord.js"; +import requireDirectory from "require-directory"; + +import Command from "./command"; + +export default class CommandManager { + readonly commands: Command[] = []; + + constructor(directory = "./commands") { + const files = requireDirectory(module, directory); + for (const i in files ) { + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.commands.push(new (files[i].default as Command)()); + } + catch (e) { + console.error(`Failed to construct command ${i} (${typeof e}):`); + console.error(e); + } + } + } + + onInteraction(interaction: Interaction) { + if ( + interaction.isChatInputCommand() || + interaction.isMessageContextMenuCommand() || + interaction.isUserContextMenuCommand() + ) { + const foundCommand = this.commands.find((command) => command.name == interaction.commandName ); + if (!foundCommand) throw new Error(`Unknown command received (${interaction.commandName}). Did you forgot to push updated commands?`); + foundCommand.execute(interaction); + return; + } + } + + bindClient(client: DiscordClient) { + client.on("interactionCreate", (e) => this.onInteraction(e)); + } +} From ae3a5133b3df81a235eaca39aaa09c47b3d96687 Mon Sep 17 00:00:00 2001 From: Wroclaw Date: Mon, 8 May 2023 09:15:34 +0200 Subject: [PATCH 6/6] Create helper script for pushing commands --- package.json | 3 ++- src/index.ts | 2 +- src/scripts/pushCommands.ts | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/scripts/pushCommands.ts diff --git a/package.json b/package.json index fc0484d..56a4f1c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "./dist/index.js", "scripts": { "start": "tsc && node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "publishCommands": "tsc && node dist/scripts/pushCommands.js" }, "author": "Wroclaw", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index 35f01ca..dbfa66d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,4 +34,4 @@ discord.on("messageCreate", async message => { queueRequest(message); }); -discord.login(config.tokens.Discord); +if (require.main === module) discord.login(config.tokens.Discord); diff --git a/src/scripts/pushCommands.ts b/src/scripts/pushCommands.ts new file mode 100644 index 0000000..5b17fbb --- /dev/null +++ b/src/scripts/pushCommands.ts @@ -0,0 +1,40 @@ +// https://discordjs.guide/creating-your-bot/command-deployment.html#guild-commands + +import { REST, RESTGetAPIOAuth2CurrentApplicationResult, RESTPostAPIApplicationCommandsJSONBody, Routes } from "discord.js"; +import config from "../config"; +import requireDirectory from "require-directory"; + +import Command from "../command"; + +const post: RESTPostAPIApplicationCommandsJSONBody[] = []; + +const guildId = process.argv.slice(2)[0]; +requireDirectory<{default: Command}, void>(module, "../commands", { + visit: function (obj) { + console.log(obj); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + post.push(new obj.default().toRESTPostApplicationCommands()); + }, +}); + +const rest = new REST().setToken(config.tokens.Discord); + +(async () => { + const me = await rest.get(Routes.oauth2CurrentApplication()) as RESTGetAPIOAuth2CurrentApplicationResult; + console.log(`Started refreshing ${post.length} application commands.`); + if (guildId && guildId != "") + await rest.put( + Routes.applicationGuildCommands(me.id, guildId), + { body: post }, + ); + else { + await rest.put( + Routes.applicationCommands(me.id), + { body: post }, + ); + } + console.log("Refreshed successfully"); +})().catch( e => { + console.error(e); +});