diff --git a/package-lock.json b/package-lock.json index c1c7dff..8bd83db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,12 +13,10 @@ "discord.js": "^14.8.0", "fold-to-ascii": "^5.0.1", "gpt-3-encoder": "^1.1.4", - "openai": "^3.2.1", - "require-directory": "^2.1.1" + "openai": "^3.2.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", @@ -297,15 +295,6 @@ "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", @@ -1721,14 +1710,6 @@ "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 56a4f1c..aba236c 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,7 @@ "main": "./dist/index.js", "scripts": { "start": "tsc && node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1", - "publishCommands": "tsc && node dist/scripts/pushCommands.js" + "test": "echo \"Error: no test specified\" && exit 1" }, "author": "Wroclaw", "license": "MIT", @@ -15,12 +14,10 @@ "discord.js": "^14.8.0", "fold-to-ascii": "^5.0.1", "gpt-3-encoder": "^1.1.4", - "openai": "^3.2.1", - "require-directory": "^2.1.1" + "openai": "^3.2.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 deleted file mode 100644 index 4ef8a64..0000000 --- a/src/command.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 0ce7407..0000000 --- a/src/commands/summon.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/execution.ts b/src/execution.ts index e55e9b5..3b4c3f1 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -5,20 +5,16 @@ 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: apiRequest) { - if (request instanceof DiscordApi.Message) return request.author; +export function getAuthor(request: DiscordApi.Message | DiscordApi.Interaction) { + if (request instanceof DiscordApi.Message) return request.member ?? request.author; return request.user; } @@ -54,41 +50,13 @@ 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 */ -export async function queueRequest(request: apiRequest) { +export async function queueRequest(request: DiscordApi.Message | DiscordApi.Interaction) { if (!request.channelId) { - if (request instanceof DiscordApi.Message) - request.reply("request does not have channelId"); - else if (request.isRepliable()) + if (!(request instanceof DiscordApi.Message) && request.isRepliable()) request.reply("request does not have channelId"); console.log("There was incoming execution without channelId set, ignoring"); console.log(request); @@ -122,7 +90,7 @@ export async function queueRequest(request: apiRequest) { () => { return []; }, ); const shouldStart = messagesForChannel.length == 0; - messagesForChannel.push(request as request); + messagesForChannel.push(request); if (shouldStart) executeFromQueue(request.channelId); } @@ -132,8 +100,8 @@ export async function queueRequest(request: apiRequest) { * @param channel the channel to run the queue for */ async function executeFromQueue(channel: string) { - const channelQueue = channelsRunning.get(channel) as NonNullable>; - const message = channelQueue.at(0) as NonNullable>; + const channelQueue = channelsRunning.get(channel) as DiscordApi.Message[]; + const message = channelQueue.at(0) as DiscordApi.Message; try { let messages: DiscordApi.Collection = await message.channel.messages.fetch({ limit: config.limits.messages, cache: false }); @@ -142,13 +110,7 @@ async function executeFromQueue(channel: string) { messages.forEach(m => Moderation.checkMessage(m)); - if (message instanceof DiscordApi.Message) { - message.channel.sendTyping(); - } - else if (message.isRepliable()) { - message.deferReply(); - } - + message.channel.sendTyping(); const answer = await openai.createChatCompletion({ ...config.chatCompletionConfig, messages: toOpenAIMessages(messages), @@ -156,13 +118,13 @@ async function executeFromQueue(channel: string) { const usage = answer.data.usage; if (usage != undefined) { - 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}`); + 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}`); database.usage.create({ data: { timestamp: message.createdAt, - user: BigInt(getAuthor(message).id), + user: BigInt(message.author.id), channel: BigInt(message.channelId), guild: message.guildId ? BigInt(message.guildId) : null, usageReguest: usage.prompt_tokens, @@ -176,30 +138,32 @@ 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: { + repliedUser: false, + } + }); - const response = requestReply(message, {content: answerContent}, {allowedMentions: { repliedUser: false }}); - - response.then(rval => Moderation.checkMessage(rval)); + Moderation.checkMessage(await response); } else { - if (message instanceof DiscordApi.Message) message.react("😶"); + message.react("😶"); } } catch (e) { console.error(`Error ocurred while handling chat completion request (${(e as object).constructor.name}):`); console.error(e); - requestReply( - message, - { - embeds: [{ - color: 0xff0000, - description: "Something bad happened! :frowning:" - }], - }, - {allowedMentions: { repliedUser: false } }, - { ephemeral: true }, - ); + message.reply({ + embeds: [{ + color: 0xff0000, + description: "Something bad happened! :frowning:" + }], + allowedMentions: { + repliedUser: false, + } + }); } channelQueue.shift(); diff --git a/src/index.ts b/src/index.ts index dbfa66d..cba9b7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ import { PrismaClient } from "@prisma/client"; import config from "./config"; import { queueRequest } from "./execution"; -import InteractionManager from "./interactionManager"; const discord = new DiscordApi.Client({ intents: [ @@ -20,9 +19,6 @@ 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})`); }); @@ -34,4 +30,4 @@ discord.on("messageCreate", async message => { queueRequest(message); }); -if (require.main === module) discord.login(config.tokens.Discord); +discord.login(config.tokens.Discord); diff --git a/src/interactionManager.ts b/src/interactionManager.ts deleted file mode 100644 index 3b7968b..0000000 --- a/src/interactionManager.ts +++ /dev/null @@ -1,40 +0,0 @@ -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)); - } -} diff --git a/src/moderation.ts b/src/moderation.ts index 64291c8..02b2540 100644 --- a/src/moderation.ts +++ b/src/moderation.ts @@ -1,39 +1,31 @@ -import { Collection, InteractionResponse, Message } from "discord.js"; +import { Collection, Message } from "discord.js"; import { openai } from "./index"; -import { formatRequestOrResponse } from "./toOpenAIMessages"; +import { formatMessage } 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 | InteractionResponse): Promise { + public static async checkMessage(message: Message): Promise { if (this.cache.has(message.id)) { return this.cache.get(message.id) as boolean; } - 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; - } + 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: await formatRequestOrResponse(message), + input: formatMessage(message), }); const flagged = answer.data.results[0].flagged; this.cache.set(message.id, flagged); - if (flagged) if (message instanceof Message) { - message.react("⚠"); - } - else { - const channelMessage = await message.fetch(); - channelMessage.react("⚠"); - } + if (flagged) message.react("⚠"); return flagged; } diff --git a/src/scripts/pushCommands.ts b/src/scripts/pushCommands.ts deleted file mode 100644 index 5b17fbb..0000000 --- a/src/scripts/pushCommands.ts +++ /dev/null @@ -1,40 +0,0 @@ -// 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); -}); diff --git a/src/toOpenAIMessages.ts b/src/toOpenAIMessages.ts index f81c32f..54e2ba4 100644 --- a/src/toOpenAIMessages.ts +++ b/src/toOpenAIMessages.ts @@ -1,25 +1,9 @@ import { ChatCompletionRequestMessage as OpenAIMessage } from "openai"; -import { Collection, Message as DiscordMessage, InteractionResponse } from "discord.js"; +import { Collection, Message as DiscordMessage } 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