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..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", @@ -14,10 +15,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/execution.ts b/src/execution.ts index 3b4c3f1..e55e9b5 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; } @@ -50,13 +54,41 @@ 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: 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 +122,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 +132,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 +142,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 +156,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, @@ -138,32 +176,30 @@ 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, - } - }); + if (answerContent != undefined || answerContent != "") { - Moderation.checkMessage(await response); + const response = requestReply(message, {content: answerContent}, {allowedMentions: { repliedUser: false }}); + + response.then(rval => Moderation.checkMessage(rval)); } 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}):`); 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(); diff --git a/src/index.ts b/src/index.ts index cba9b7b..dbfa66d 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})`); }); @@ -30,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/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)); + } +} 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/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); +}); 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