Compare commits

...

6 commits

Author SHA1 Message Date
ae3a5133b3 Create helper script for pushing commands 2023-05-08 09:15:34 +02:00
8b4b35454b 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
2023-05-08 08:53:06 +02:00
56a0e686b0 fully prepare execution for interactions 2023-05-08 08:51:30 +02:00
28dce0b29f Add support for interactions in moderation 2023-05-08 08:50:59 +02:00
f6ac5281e7 Prepare more execution.ts for interactions 2023-05-08 08:50:23 +02:00
cb2ae4d4f2 Fix always false if statement 2023-05-08 07:12:08 +02:00
10 changed files with 269 additions and 46 deletions

21
package-lock.json generated
View file

@ -13,10 +13,12 @@
"discord.js": "^14.8.0", "discord.js": "^14.8.0",
"fold-to-ascii": "^5.0.1", "fold-to-ascii": "^5.0.1",
"gpt-3-encoder": "^1.1.4", "gpt-3-encoder": "^1.1.4",
"openai": "^3.2.1" "openai": "^3.2.1",
"require-directory": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/fold-to-ascii": "^5.0.0", "@types/fold-to-ascii": "^5.0.0",
"@types/require-directory": "^2.1.2",
"@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0", "@typescript-eslint/parser": "^5.55.0",
"eslint": "^8.36.0", "eslint": "^8.36.0",
@ -295,6 +297,15 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.2.tgz",
"integrity": "sha512-sDPHm2wfx2QhrMDK0pOt2J4KLJMAcerqWNvnED0itPRJWvI+bK+uNHzcH1dFsBlf7G3u8tqXmRF3wkvL9yUwMw==" "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": { "node_modules/@types/semver": {
"version": "7.3.13", "version": "7.3.13",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz",
@ -1710,6 +1721,14 @@
"url": "https://github.com/sponsors/Borewit" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",

View file

@ -5,7 +5,8 @@
"main": "./dist/index.js", "main": "./dist/index.js",
"scripts": { "scripts": {
"start": "tsc && node dist/index.js", "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", "author": "Wroclaw",
"license": "MIT", "license": "MIT",
@ -14,10 +15,12 @@
"discord.js": "^14.8.0", "discord.js": "^14.8.0",
"fold-to-ascii": "^5.0.1", "fold-to-ascii": "^5.0.1",
"gpt-3-encoder": "^1.1.4", "gpt-3-encoder": "^1.1.4",
"openai": "^3.2.1" "openai": "^3.2.1",
"require-directory": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/fold-to-ascii": "^5.0.0", "@types/fold-to-ascii": "^5.0.0",
"@types/require-directory": "^2.1.2",
"@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0", "@typescript-eslint/parser": "^5.55.0",
"eslint": "^8.36.0", "eslint": "^8.36.0",

41
src/command.ts Normal file
View file

@ -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<Type extends keyof InteractionTypeMap = ApplicationCommandType> {
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<Type extends keyof InteractionTypeMap = ApplicationCommandType> {
abstract execute(interaction: InteractionTypeMap[Type][0]): Promise<void>;
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;

16
src/commands/summon.ts Normal file
View file

@ -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);
}
}

View file

@ -5,16 +5,20 @@ import Moderation from "./moderation";
import config from "./config"; import config from "./config";
import toOpenAIMessages from "./toOpenAIMessages"; import toOpenAIMessages from "./toOpenAIMessages";
type NonNullableInObject<T, V> = { [k in keyof T]: k extends V ? NonNullable<T[k]> : T[k] };
type apiRequest = DiscordApi.Message | DiscordApi.RepliableInteraction;
export type request = apiRequest & NonNullableInObject<apiRequest, "channel" | "channelId">;
/** Stores the queue requests on the channels. */ /** Stores the queue requests on the channels. */
const channelsRunning: DiscordApi.Collection<string, (DiscordApi.Message | DiscordApi.Interaction)[]> = new DiscordApi.Collection(); const channelsRunning: DiscordApi.Collection<string, request[]> = new DiscordApi.Collection();
/** /**
* Gets the user that requested the execution * Gets the user that requested the execution
* @param request The request to get the user from * @param request The request to get the user from
* @returns the user or guild member * @returns the user or guild member
*/ */
export function getAuthor(request: DiscordApi.Message | DiscordApi.Interaction) { export function getAuthor(request: apiRequest) {
if (request instanceof DiscordApi.Message) return request.member ?? request.author; if (request instanceof DiscordApi.Message) return request.author;
return request.user; return request.user;
} }
@ -50,13 +54,41 @@ async function getUserLimit(user: string | {id: string}, requestTimestamp: Date)
return {limit: userLimits.limit, remaining: userLimits.limit - usedLimit}; 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. * Check and queues up the request and runs it if there is nothing in queue.
* @param request the message to check and 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.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"); request.reply("request does not have channelId");
console.log("There was incoming execution without channelId set, ignoring"); console.log("There was incoming execution without channelId set, ignoring");
console.log(request); console.log(request);
@ -90,7 +122,7 @@ export async function queueRequest(request: DiscordApi.Message | DiscordApi.Inte
() => { return []; }, () => { return []; },
); );
const shouldStart = messagesForChannel.length == 0; const shouldStart = messagesForChannel.length == 0;
messagesForChannel.push(request); messagesForChannel.push(request as request);
if (shouldStart) if (shouldStart)
executeFromQueue(request.channelId); 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 * @param channel the channel to run the queue for
*/ */
async function executeFromQueue(channel: string) { async function executeFromQueue(channel: string) {
const channelQueue = channelsRunning.get(channel) as DiscordApi.Message[]; const channelQueue = channelsRunning.get(channel) as NonNullable<ReturnType<typeof channelsRunning.get>>;
const message = channelQueue.at(0) as DiscordApi.Message; const message = channelQueue.at(0) as NonNullable<ReturnType<typeof channelQueue.at>>;
try { try {
let messages: DiscordApi.Collection<string, DiscordApi.Message> = await message.channel.messages.fetch({ limit: config.limits.messages, cache: false }); let messages: DiscordApi.Collection<string, DiscordApi.Message> = 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)); 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({ const answer = await openai.createChatCompletion({
...config.chatCompletionConfig, ...config.chatCompletionConfig,
messages: toOpenAIMessages(messages), messages: toOpenAIMessages(messages),
@ -118,13 +156,13 @@ async function executeFromQueue(channel: string) {
const usage = answer.data.usage; const usage = answer.data.usage;
if (usage != undefined) { if (usage != undefined) {
const channelName: string = message.inGuild() ? `${message.channel.name} (${message.guild.name})` : `@${message.author.tag}`; 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 ${message.author.tag} (${message.author.id}) in #${channelName}`); 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({ database.usage.create({
data: { data: {
timestamp: message.createdAt, timestamp: message.createdAt,
user: BigInt(message.author.id), user: BigInt(getAuthor(message).id),
channel: BigInt(message.channelId), channel: BigInt(message.channelId),
guild: message.guildId ? BigInt(message.guildId) : null, guild: message.guildId ? BigInt(message.guildId) : null,
usageReguest: usage.prompt_tokens, usageReguest: usage.prompt_tokens,
@ -138,32 +176,30 @@ async function executeFromQueue(channel: string) {
const answerContent = answer.data.choices[0].message?.content; 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,
}
});
Moderation.checkMessage(await response); const response = requestReply(message, {content: answerContent}, {allowedMentions: { repliedUser: false }});
response.then(rval => Moderation.checkMessage(rval));
} }
else { else {
message.react("😶"); if (message instanceof DiscordApi.Message) message.react("😶");
} }
} catch (e) { } catch (e) {
console.error(`Error ocurred while handling chat completion request (${(e as object).constructor.name}):`); console.error(`Error ocurred while handling chat completion request (${(e as object).constructor.name}):`);
console.error(e); console.error(e);
message.reply({ requestReply(
embeds: [{ message,
color: 0xff0000, {
description: "Something bad happened! :frowning:" embeds: [{
}], color: 0xff0000,
allowedMentions: { description: "Something bad happened! :frowning:"
repliedUser: false, }],
} },
}); {allowedMentions: { repliedUser: false } },
{ ephemeral: true },
);
} }
channelQueue.shift(); channelQueue.shift();

View file

@ -4,6 +4,7 @@ import { PrismaClient } from "@prisma/client";
import config from "./config"; import config from "./config";
import { queueRequest } from "./execution"; import { queueRequest } from "./execution";
import InteractionManager from "./interactionManager";
const discord = new DiscordApi.Client({ const discord = new DiscordApi.Client({
intents: [ intents: [
@ -19,6 +20,9 @@ export const openai = new OpenAIApi(new OpenAIApiConfiguration({
export const database = new PrismaClient(); export const database = new PrismaClient();
const interactionManager = new InteractionManager();
interactionManager.bindClient(discord);
discord.on("ready", async event => { discord.on("ready", async event => {
console.log(`Connected to Discord as ${event.user.tag} (${event.user.id})`); console.log(`Connected to Discord as ${event.user.tag} (${event.user.id})`);
}); });
@ -30,4 +34,4 @@ discord.on("messageCreate", async message => {
queueRequest(message); queueRequest(message);
}); });
discord.login(config.tokens.Discord); if (require.main === module) discord.login(config.tokens.Discord);

40
src/interactionManager.ts Normal file
View file

@ -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));
}
}

View file

@ -1,31 +1,39 @@
import { Collection, Message } from "discord.js"; import { Collection, InteractionResponse, Message } from "discord.js";
import { openai } from "./index"; import { openai } from "./index";
import { formatMessage } from "./toOpenAIMessages"; import { formatRequestOrResponse } from "./toOpenAIMessages";
export default class Moderation { export default class Moderation {
/** Represents cache of messages that have been checked aganist OpenAI moderation API. */ /** Represents cache of messages that have been checked aganist OpenAI moderation API. */
private static cache = new Collection<string, boolean>(); private static cache = new Collection<string, boolean>();
public static async checkMessage(message: Message): Promise<boolean> { public static async checkMessage(message: Message | InteractionResponse): Promise<boolean> {
if (this.cache.has(message.id)) { if (this.cache.has(message.id)) {
return this.cache.get(message.id) as boolean; return this.cache.get(message.id) as boolean;
} }
const warningReaction = message.reactions.resolve("⚠"); if (message instanceof Message) {
// if bot reacted to that message, we already know that it returned true for moderation API const warningReaction = message.reactions.resolve("⚠");
if (warningReaction && warningReaction.me) { // if bot reacted to that message, we already know that it returned true for moderation API
this.cache.set(message.id, true); if (warningReaction && warningReaction.me) {
return true; this.cache.set(message.id, true);
return true;
}
} }
try{ try {
const answer = await openai.createModeration({ const answer = await openai.createModeration({
input: formatMessage(message), input: await formatRequestOrResponse(message),
}); });
const flagged = answer.data.results[0].flagged; const flagged = answer.data.results[0].flagged;
this.cache.set(message.id, 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; return flagged;
} }

View file

@ -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);
});

View file

@ -1,9 +1,25 @@
import { ChatCompletionRequestMessage as OpenAIMessage } from "openai"; 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 FoldToAscii from "fold-to-ascii";
import config from "./config"; import config from "./config";
import countTokens from "./tokenCounter"; 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<string> {
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 * Formats the message to use as a message content in OpenAI api