Compare commits

..

No commits in common. "main" and "feature/quota" have entirely different histories.

19 changed files with 737 additions and 1339 deletions

View file

@ -12,20 +12,7 @@ RUN npx prisma generate
# Typescript compiling
COPY tsconfig.json .
COPY src ./src
COPY scripts ./scripts
RUN npx tsc
# Permissions for dist directory,
# so config.ts can be compiled there during runtime
# regardless of the user of the container
RUN chmod 777 dist
# Create a db directory for sqlite database file
# it is required because in order to write to sqlite file
# the directory must be writable
RUN mkdir /db
RUN chmod 777 /db
# Run the app
CMD ["node", "dist/src/index.js"]
STOPSIGNAL SIGINT
CMD ["node", "dist/index.js"]

1118
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,30 +2,29 @@
"name": "gptcord",
"version": "0.1.0",
"description": "",
"main": "./dist/src/index.js",
"main": "./dist/index.js",
"scripts": {
"start": "tsc && node dist/src/index.js",
"start": "tsc && node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"publishCommands": "tsc && node dist/scripts/pushCommands.js"
},
"author": "Wroclaw",
"license": "MIT",
"dependencies": {
"@prisma/client": "5.13.0",
"discord.js": "14.14.1",
"@prisma/client": "^5.0.0",
"discord.js": "^14.8.0",
"fold-to-ascii": "^5.0.1",
"gpt-3-encoder": "^1.1.4",
"openai": "^4.38.3",
"require-directory": "^2.1.1",
"typescript": "^5.4.5"
"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": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
"eslint": "^8.57.0",
"json-schema-to-ts": "^3.0.1",
"prisma": "5.13.0"
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"eslint": "^8.46.0",
"prisma": "^5.0.0",
"typescript": "^5.1.6"
}
}

View file

@ -1,66 +0,0 @@
// https://discordjs.guide/creating-your-bot/command-deployment.html#guild-commands
import { REST, RESTGetAPIOAuth2CurrentApplicationResult, RESTPostAPIApplicationCommandsJSONBody, Routes } from "discord.js";
import { config } from "../src/index";
import requireDirectory from "require-directory";
import
Command
, {
ApplicationIntegrationType
, InteractionContextTypes
, InteractionTypeMap
} from "../src/command";
const post: RESTPostAPIApplicationCommandsJSONBody[] = [];
const guildId = process.argv.slice(2)[0];
const importedCommands = requireDirectory(module, "../src/commands");
function isGuildCommand(command: Command<keyof InteractionTypeMap>): boolean {
// guild Commmand is when it's a guild install and context is guild (and these are defaults if not provided)
return (command.integration_types?.includes(ApplicationIntegrationType.Guild_Install) ?? true)
&& (command.contexts?.includes(InteractionContextTypes.Guild) ?? true);
}
for (const obj in importedCommands) {
try {
const allExports = importedCommands[obj] as {default: unknown};
const defaultExport = allExports.default;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const constructedExport = new defaultExport() as unknown;
if (!(constructedExport instanceof Command)) throw new Error(`${obj}'s default does not extends Command`);
if (guildId && guildId !== "" && isGuildCommand(constructedExport as Command<keyof InteractionTypeMap>)) {
console.log(`Skipping ${obj} because it's not a guild command`);
continue;
}
post.push(constructedExport.toRESTPostApplicationCommands());
} catch (e) {
console.error(e);
}
}
const rest = new REST().setToken(config.tokens.Discord);
(async () => {
const me = await rest.get(Routes.oauth2CurrentApplication()) as RESTGetAPIOAuth2CurrentApplicationResult;
if (guildId && guildId !== "") {
console.log(`Started refreshing ${post.length} application guild (${guildId}) commands.`);
await rest.put(
Routes.applicationGuildCommands(me.id, guildId),
{ body: post },
);
} else {
console.log(`Started refreshing ${post.length} application global commands.`);
await rest.put(
Routes.applicationCommands(me.id),
{ body: post },
);
}
console.log("Refreshed successfully");
process.exit(0);
})().catch( e => {
console.error(e);
process.exit(1);
});

View file

@ -1,78 +0,0 @@
{ pkgs ? import <nixpkgs> {}
, unstable ? import <nixos-unstable> {}
}:
let
prisma-version = "5.13.0";
prisma-src = pkgs.fetchFromGitHub {
owner = "prisma";
repo = "prisma-engines";
rev = prisma-version;
hash = "sha256-8LC2RV3FRr1F0TZxQNxvvEoTyhKusgzB5omlxLAnHG0=";
};
new-prisma-engines = unstable.rustPlatform.buildRustPackage {
pname = "prisma-engines";
version = prisma-version;
src = builtins.storePath prisma-src;
# Use system openssl.
OPENSSL_NO_VENDOR = 1;
nativeBuildInputs = [ pkgs.pkg-config pkgs.git ];
buildInputs = [
pkgs.openssl
pkgs.protobuf
];
cargoLock = {
lockFile = "${prisma-src}/Cargo.lock";
outputHashes = {
"cuid-1.3.2" = "sha256-qBu1k/dJiA6rWBwk4nOOqouIneD9h2TTBT8tvs0TDfA=";
"barrel-0.6.6-alpha.0" = "sha256-USh0lQ1z+3Spgc69bRFySUzhuY79qprLlEExTmYWFN8=";
"graphql-parser-0.3.0" = "sha256-0ZAsj2mW6fCLhwTETucjbu4rPNzfbNiHu2wVTBlTNe4=";
"mysql_async-0.31.3" = "sha256-2wOupQ/LFV9pUifqBLwTvA0tySv+XWbxHiqs7iTzvvg=";
"postgres-native-tls-0.5.0" = "sha256-UYPsxhCkXXWk8yPbqjNS0illwjS5mVm3Z/jFwpVwqfw=";
};
};
preBuild = ''
export OPENSSL_DIR=${pkgs.lib.getDev pkgs.openssl}
export OPENSSL_LIB_DIR=${pkgs.lib.getLib pkgs.openssl}/lib
export PROTOC=${pkgs.protobuf}/bin/protoc
export PROTOC_INCLUDE="${pkgs.protobuf}/include";
export SQLITE_MAX_VARIABLE_NUMBER=250000
export SQLITE_MAX_EXPR_DEPTH=10000
'';
cargoBuildFlags = [
"-p" "query-engine"
"-p" "query-engine-node-api"
"-p" "schema-engine-cli"
"-p" "prisma-fmt"
];
postInstall = ''
mv $out/lib/libquery_engine${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} $out/lib/libquery_engine.node
'';
# Tests are long to compile
doCheck = false;
};
in
pkgs.mkShell {
nativeBuildInputs = [
new-prisma-engines
pkgs.nodejs_18
pkgs.openssl
];
shellHook = ''
export PRISMA_SCHEMA_ENGINE_BINARY="${new-prisma-engines}/bin/schema-engine"
export PRISMA_QUERY_ENGINE_BINARY="${new-prisma-engines}/bin/query-engine"
export PRISMA_QUERY_ENGINE_LIBRARY="${new-prisma-engines}/lib/libquery_engine.node"
export PRISMA_FMT_BINARY="${new-prisma-engines}/bin/prisma-fmt"
'';
}

View file

@ -1,61 +1,23 @@
import { AutocompleteInteraction, PermissionsBitField } from "discord.js";
import { RESTPostAPIApplicationCommandsJSONBody } from "discord.js";
import { APIApplicationCommandOption, ApplicationCommandType, ChatInputCommandInteraction, LocalizationMap, MessageInteraction, PermissionResolvable, UserSelectMenuInteraction } from "discord.js";
import { ApplicationCommandOption, ApplicationCommandType, ChatInputCommandInteraction, LocalizationMap, MessageInteraction, PermissionResolvable, UserSelectMenuInteraction } from "discord.js";
export type InteractionTypeMap = {
// [CommandType]: [Interaction, Description]
type InteractionTypeMap = {
[ApplicationCommandType.ChatInput]: [ChatInputCommandInteraction, string];
[ApplicationCommandType.Message]: [MessageInteraction, never];
[ApplicationCommandType.User]: [UserSelectMenuInteraction, never];
};
// TODO: At time of coding, Discord api types doesn't support user installations of bot/application yet
// replace this with the types from the discord api types when it's available
/**
* https://discord.com/developers/docs/resources/application#application-object-application-integration-types
*/
export enum ApplicationIntegrationType {
Guild_Install = 0,
User_Install = 1,
}
/**
* https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-context-types
*/
export enum InteractionContextTypes {
Guild = 0,
BotDM = 1,
PrivateChannel = 2,
}
/**
* https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure
*/
export type FutureRESTPostAPIApplicationCommandsJSONBody =
RESTPostAPIApplicationCommandsJSONBody
& {
/**
* @deprecated use contexts instead
*/
dm_permission?: boolean;
integration_types?: ApplicationIntegrationType[];
contexts?: InteractionContextTypes[];
};
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?: APIApplicationCommandOption[];
readonly options?: ApplicationCommandOption[];
readonly default_member_permissions?: PermissionResolvable;
readonly type: Type;
readonly nsfw?: boolean;
/** @deprecated use contexts instead */
readonly dm_permission?: boolean;
readonly integration_types?: ApplicationIntegrationType[];
readonly contexts?: InteractionContextTypes[];
}
abstract class Command<Type extends keyof InteractionTypeMap = ApplicationCommandType> {
@ -63,7 +25,7 @@ abstract class Command<Type extends keyof InteractionTypeMap = ApplicationComman
autocomplete?(interaction: Type extends ApplicationCommandType.ChatInput ? AutocompleteInteraction : never ): Promise<void>;
toRESTPostApplicationCommands(): FutureRESTPostAPIApplicationCommandsJSONBody {
toRESTPostApplicationCommands(): RESTPostAPIApplicationCommandsJSONBody {
return {
name: this.name,
name_localizations: this.name_localizations,
@ -74,8 +36,6 @@ abstract class Command<Type extends keyof InteractionTypeMap = ApplicationComman
type: this.type,
nsfw: this.nsfw,
dm_permission: this.dm_permission,
integration_types: this.integration_types,
contexts: this.contexts,
};
}
}

View file

@ -1,90 +0,0 @@
import {
APIApplicationCommandOption
, ApplicationCommandOptionType
, ApplicationCommandType
, ChatInputCommandInteraction
} from "discord.js";
import { ChatCompletionMessageParam } from "openai/resources";
import
Command
,{ApplicationIntegrationType
, InteractionContextTypes
} from "../command";
import { config } from "../index";
import { executeChatCompletion, replyInMultiMessage } from "../execution";
import { formatName } from "../toOpenAIMessages";
export default class Ask extends Command implements Command {
name = "ask";
description = "Promts the bot to reply to a single message without any history context";
type = ApplicationCommandType.ChatInput;
options: APIApplicationCommandOption[] = [
{
name: "content",
description: "The content of the prompt",
type: ApplicationCommandOptionType.String,
required: true,
},
{
name: "ephemeral",
description: "if true, only you can see the response (default true)",
type: ApplicationCommandOptionType.Boolean,
required: false,
}
];
integration_types = [
ApplicationIntegrationType.Guild_Install,
ApplicationIntegrationType.User_Install,
];
contexts = [
InteractionContextTypes.Guild,
InteractionContextTypes.BotDM,
InteractionContextTypes.PrivateChannel,
];
async execute(interaction: ChatInputCommandInteraction) {
const content = interaction.options.getString("content", true);
const ephemeral = interaction.options.getBoolean("ephemeral", false) ?? true;
if (!interaction.channel && !interaction.channelId) {
console.error("No channel found in interaction");
console.error(interaction);
await interaction.reply({
content: "No channel found in interaction???",
ephemeral: true
});
return;
}
const userLimit = await config.quota.checkUser(interaction.user, interaction);
if (userLimit.used >= userLimit.quota) {
interaction.reply({
embeds: [{
color: 0xff0000,
description: "You've used up your quota,\n" + userLimit.toString(),
}],
ephemeral: true,
}).catch(e => {
console.error("Failed to reply to user: ", e);
});
return;
}
// TODO: check content in moderation API
const messages: ChatCompletionMessageParam[] = [
...config.systemPrompt(interaction),
{ role: "user", name: formatName(interaction.user.displayName), content }
];
const [answer] = await Promise.all([
executeChatCompletion(messages, interaction),
interaction.deferReply({ ephemeral }),
]);
await replyInMultiMessage(answer.choices[0].message.content, interaction);
}
}

View file

@ -1,20 +1,16 @@
import { ApplicationCommandType, ChatInputCommandInteraction, APIApplicationCommandOption, ApplicationCommandOptionType, APIEmbedField } from "discord.js";
import { ApplicationCommandType, ChatInputCommandInteraction, ApplicationCommandOption, ApplicationCommandOptionType, APIEmbedField } from "discord.js";
import
Command
,{ApplicationIntegrationType
, InteractionContextTypes
} from "../command";
import Command from "../command";
import { config } from "../index";
export default class MyLimit extends Command implements Command {
name = "check-quota";
description = "Checks your quota and usage";
name = "check-limit";
description = "Checks your limit and usage";
type = ApplicationCommandType.ChatInput;
options: APIApplicationCommandOption[] = [
options: ApplicationCommandOption[] = [
{
name: "recovery-for",
description: "Get the recovery time for given quota units count (default: until can use the bot or 1)",
description: "Calculate the limit recovery time for given message count (default 1)",
type: ApplicationCommandOptionType.Integer,
required: false,
},
@ -22,34 +18,13 @@ export default class MyLimit extends Command implements Command {
name: "ephemeral",
description: "if true, only you can see the response (default true)",
type: ApplicationCommandOptionType.Boolean,
required: false,
}
];
integration_types = [
ApplicationIntegrationType.Guild_Install,
ApplicationIntegrationType.User_Install
];
contexts = [
InteractionContextTypes.Guild,
InteractionContextTypes.BotDM,
InteractionContextTypes.PrivateChannel
];
async execute(interaction: ChatInputCommandInteraction) {
let recoveryFor = interaction.options.getInteger("recovery-for", false);
let recoveryFor = interaction.options.getInteger("recovery-for", false) ?? 1;
const ephemeral = interaction.options.getBoolean("ephemeral", false) ?? true;
if (recoveryFor === null) {
const userQuota = await config.quota.checkUser(
interaction.user,
interaction,
);
const remaining = userQuota.quota - userQuota.used;
// NOTE: In order for user to execute the bot again, the used must be lower than quota
// user can't execute if quota and used are equal
// thus why we add 1 here
recoveryFor = remaining >= 0 ? 1 : 1 - remaining;
}
if (recoveryFor <= 0) recoveryFor = 1;
const userQuotaRecovery = await config.quota.getUserQuotaRecovery(interaction.user, interaction, recoveryFor);

View file

@ -1,26 +0,0 @@
import {ApplicationCommandType, ChatInputCommandInteraction } from "discord.js";
import Command, { ApplicationIntegrationType, InteractionContextTypes } from "../command";
export default class Listen extends Command implements Command {
// This command exists because Discord bots don't receive direct messages
// unless they explicitly open a DM channel with the user
name = "listen";
description = "Makes the bot listen on your direct messages";
type = ApplicationCommandType.ChatInput;
options = [];
integration_types = [
ApplicationIntegrationType.Guild_Install,
ApplicationIntegrationType.User_Install
];
contexts = [InteractionContextTypes.BotDM];
async execute(interaction: ChatInputCommandInteraction) {
await interaction.user.createDM();
await interaction.reply({
content: "I'm now listening to your direct messages",
ephemeral: true,
});
}
}

View file

@ -1,29 +1,23 @@
import { Message } from "discord.js";
import {
ActivityType
, type PresenceStatusData
, type PresenceData
} from "discord.js";
import {
ChatCompletionMessageParam as OpenAIMessage,
ChatCompletionCreateParamsNonStreaming as ChatCompletionRequestData,
} from "openai/resources/chat";
ChatCompletionRequestMessage as OpenAIMessage,
CreateChatCompletionRequest as ChatCompletionRequestData,
} from "openai";
import IQuota from "./IQuota";
import MessageCount from "./quota/messageCount";
import { apiRequest } from "./execution";
export interface IConfigRequired {
readonly calendarParams: Intl.DateTimeFormatOptions;
/** Tokens to authentiate with */
readonly tokens: {
readonly Discord: string;
readonly OpenAI: string;
};
/** Discord bot status */
readonly status: PresenceData
/** Messages to append at the start of every chat history when sending to API */
systemPrompt(context: apiRequest): OpenAIMessage[];
systemPrompt(context: Message): OpenAIMessage[];
/** OpenAI model config */
readonly chatCompletionParams: Omit<ChatCompletionRequestData, "messages" | "function_call" | "tool_call" | "functions" | "n">;
readonly chatCompletionParams: Omit<ChatCompletionRequestData, "messages" | "function_call" | "functions" | "n">;
/** Limits for message selection */
readonly readLimits: {
/** Maximum message age to include (in miliseconds) */
@ -46,10 +40,6 @@ export default function newConfig(config?: IConfig): IConfigRequired {
return { ...defaultConfig, ...config };
}
function isEnvDefined(key: string): boolean {
return process.env[key] !== undefined;
}
function envAsString(key: string): string | undefined {
key = key.toLocaleUpperCase();
return process.env[key];
@ -61,54 +51,16 @@ function envAsNumber(key: string): number | undefined {
return !Number.isNaN(value) ? value : undefined;
}
function envAsBoolean(key: string): boolean | undefined {
key = key.toUpperCase();
const value = process.env[key];
return !(value === "false" || value === "0");
}
function envAsActivityType(key: string): ActivityType | undefined {
key = key.toUpperCase();
const value = process.env[key]?.toUpperCase();
switch (value) {
case "0":
case "PLAYING":
return ActivityType.Playing;
case "1":
case "STREAMING":
return ActivityType.Streaming;
case "2":
case "LISTENING":
return ActivityType.Listening;
case "3":
case "WATCHING":
return ActivityType.Watching;
case "4":
case "CUSTOM":
return ActivityType.Custom;
case "5":
case "COMPETING":
return ActivityType.Competing;
default:
return undefined;
}
}
function envAsPresenceStatusData(key: string): PresenceStatusData | undefined {
key = key.toUpperCase();
const value = process.env[key]?.toLowerCase();
switch (value) {
case "online":
case "idle":
case "dnd":
case "invisible":
return value;
default:
return undefined;
}
}
const defaultConfig: IConfigRequired = {
calendarParams: {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
tokens: {
Discord: envAsString("TOKENS__DISCORD") ?? "",
OpenAI: envAsString("TOKENS__OPENAI") ?? "",
@ -123,16 +75,6 @@ const defaultConfig: IConfigRequired = {
},
];
},
status: {
activities: isEnvDefined("STATUS__NAME") ? [{
name: envAsString("STATUS__NAME") as string,
type: envAsActivityType("STATUS__TYPE") ?? ActivityType.Custom,
state: envAsString("STATUS__STATE"),
url: envAsString("STATUS__URL"),
}] : undefined,
status: envAsPresenceStatusData("STATUS__STATUS"),
afk: envAsBoolean("STATUS__AFK"),
},
chatCompletionParams: {
model: envAsString("CHAT_COMPLETION_PARAMS__MODEL") ?? "gpt-3.5-turbo",
max_tokens: envAsNumber("CHAT_COMPLETION_PARAMS__MAX_TOKENS") ?? 384,

View file

@ -1,10 +1,6 @@
import DiscordApi, { GuildTextBasedChannel, TextBasedChannel } from "discord.js";
import {APIError as OpenAIError} from "openai";
import {
ChatCompletion,
ChatCompletionMessage,
ChatCompletionMessageParam
} from "openai/resources/chat";
import { ChatCompletionRequestMessage, ChatCompletionResponseMessage } from "openai";
import Axios from "axios";
import { database, openai, config } from "./index";
import Moderation from "./moderation";
@ -73,7 +69,7 @@ export function getAuthor(request: apiRequest) {
* @returns Promise of the done action
*/
function requestReply(
request: apiRequest,
request: RequestMessage,
message: DiscordApi.MessageReplyOptions & DiscordApi.InteractionReplyOptions,
// TODO: add support for these below
replyOptions: DiscordApi.MessageReplyOptions = {},
@ -172,42 +168,29 @@ export async function queueRequest(request: apiRequest) {
* Logs used tokens to the terminal and to the database
* @param answer the response that OpenAI returned
* @param message the message that initiated the execution
* @param functionRan counter of how many function have been ran (to distinct records in database)
* @param functionRan counter of how many function have been ran
*/
function logUsedTokens(
answer: ChatCompletion,
message: apiRequest | undefined = undefined,
functionRan: number = 0,
answer: Awaited<ReturnType<typeof openai.createChatCompletion>>,
message: RequestMessage,
functionRan: number,
) {
const usage = answer.usage;
const functionNames =
answer.choices[0].message.tool_calls?.map(
v => v.type === "function" ? v.function.name : `[unknown type]`
);
const usage = answer.data.usage;
const functionName = answer.data.choices[0].message?.function_call?.name;
if (usage !== undefined) {
if (!message) {
// log usage to stdout even if we can't store it in database
console.warn(`Used ${usage.total_tokens} (${usage.prompt_tokens} + ${usage.completion_tokens}) tokens from unknown call`);
// it doesn't make sense to store usage in database if we don't know where it came from
return;
}
const channelName: string = !message.channelId ? "[No channel]"
: !message.channel ? `[Unknown channel: ${message.channelId}]`
: !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}${functionNames && functionNames.length > 0 ? " [Tools: " + functionNames.join(", ") + "]" : ""}`);
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}${functionName ? " [Function: " + functionName + "]" : ""}`);
database.usage.create({
data: {
timestamp: message.createdAt,
user: BigInt(getAuthor(message).id),
channel: BigInt(message.channelId ?? 0),
channel: BigInt(message.channelId),
guild: message.guildId ? BigInt(message.guildId) : null,
usageRequest: usage.prompt_tokens,
usageResponse: usage.completion_tokens,
functionName: functionNames?.join(", ") ?? null,
functionRan: functionRan,
functionName: functionName ?? null,
functionRan: functionName ? functionRan : 0,
}
}).catch((e => {
console.error("Failed to push to a database");
@ -223,7 +206,8 @@ function logUsedTokens(
async function executeFromQueue(channel: string) {
const channelQueue = channelsRunning.get(channel) as ChannelsRunningValue;
const message = channelQueue.at(0) as RequestMessage;
let OpenAImessages: ChatCompletionMessageParam[] = [];
let functionRanCounter = 0;
let OpenAImessages: ChatCompletionRequestMessage[] = [];
// ignore if we can't even send anything to reply
if (!canReplyToRequest(message)) return;
@ -250,46 +234,76 @@ async function executeFromQueue(channel: string) {
});
OpenAImessages = toOpenAIMessages(messages.values());
const answer = await executeChatCompletion(OpenAImessages, message);
let generatedMessage: ChatCompletionResponseMessage | undefined = undefined;
let answer: Awaited<ReturnType<typeof openai.createChatCompletion>>;
do {
answer = await openai.createChatCompletion({
...config.chatCompletionParams,
messages: OpenAImessages,
// FIXME: don't use new instance of FunctionManager
functions: new FunctionManager().getFunctions(),
});
logUsedTokens(answer, message, ++functionRanCounter);
generatedMessage = answer.data.choices[0].message;
if (!generatedMessage) throw new Error("Empty message received");
// handle function calls
if (generatedMessage.function_call) {
OpenAImessages.push(generatedMessage);
// FIXME: don't use new instance of FunctionManager
OpenAImessages.push(
new FunctionManager().handleFunction(generatedMessage.function_call)
);
}
} while (generatedMessage.function_call);
channelQueue.stopTyping();
const answerContent = answer.choices[0].message?.content;
const answerContent = answer.data.choices[0].message?.content;
await replyInMultiMessage(answerContent, message);
if (answerContent === undefined || answerContent === "") {
if (message instanceof DiscordApi.Message) message.react("😶").catch(() => {/* GRACEFAIL: It's okay if the bot won't reply */});
}
else {
const answerMessagesContent :string[] = [""];
for (const i of answerContent.split(/\n\n/)) {
if (answerMessagesContent[answerMessagesContent.length-1].length + i.length < 2000) {
answerMessagesContent[answerMessagesContent.length-1] += "\n\n" + i;
}
else {
answerMessagesContent.push(i);
}
}
for (const i of answerMessagesContent) {
const response = requestReply(message, {content: i}, {allowedMentions: { repliedUser: false }});
await response.then(rval => Moderation.checkMessageNoReturn(rval));
}
}
} catch (e) {
let errorText: string = "";
channelQueue.stopTyping();
if (typeof e !== "object") {
console.error(`Error ocurred while handling chat completion request (${typeof e}):`);
console.error(`Error ocurred while handling chat completion request (${(e as object).constructor.name}):`);
console.error(e);
}
else if (e === null) {
console.error ("Error ocurred while handling chat completion request: null");
}
else {
console.error(`Error ocurred while handling chat completion request (${e.constructor.name}):`);
if (e instanceof OpenAIError) {
console.error(JSON.stringify(e));
}
else {
console.error(e);
}
if (OpenAImessages.length !== 0) {
console.error("Messages:");
console.error(OpenAImessages);
}
let errorText = "\n";
if (e instanceof Error) {
errorText = e.message;
errorText += e.message;
}
else errorText = "";
if (e instanceof OpenAIError && e.code?.match(/^5..$/) && channelQueue.tries < 3) {
if (Axios.isAxiosError(e) && e.code?.match(/^5..$/) && channelQueue.tries < 3) {
channelQueue.tries++;
await new Promise(r => setTimeout(r, 2000)); // pause for 2 seconds before retrying
return executeFromQueue(channel);
}
}
requestReply(
message,
@ -310,70 +324,3 @@ async function executeFromQueue(channel: string) {
else
return executeFromQueue(channel);
}
/**
* Replies to a message and splits to multiple messages if needed.
* @param answerContent - The content of the answer.
* @param message - The request message to reply to.
*/
export async function replyInMultiMessage(answerContent: string | null, message: apiRequest) {
if (answerContent === null || answerContent === "") {
if (message instanceof DiscordApi.Message) message.react("😶").catch(() => { });
}
else {
const answerMessagesContent: string[] = [""];
for (const i of answerContent.split(/\n\n/)) {
if (answerMessagesContent[answerMessagesContent.length - 1].length + i.length < 2000) {
answerMessagesContent[answerMessagesContent.length - 1] += "\n\n" + i;
}
else {
answerMessagesContent.push(i);
}
}
for (const i of answerMessagesContent) {
const response = requestReply(message, { content: i }, { allowedMentions: { repliedUser: false } });
await response.then(rval => Moderation.checkMessageNoReturn(rval));
}
}
}
/**
* Executes the chat completion process.
*
* @param OpenAImessages An array of ChatCompletionMessageParam objects representing the messages for chat completion.
* @param message An optional RequestMessage object representing the request message, used for logging.
* @returns A Promise that resolves to the answer from the chat completion process.
*/
export async function executeChatCompletion(
OpenAImessages: ChatCompletionMessageParam[],
message: apiRequest | undefined,
) {
let generatedMessage: ChatCompletionMessage | undefined = undefined;
let answer: Awaited<ReturnType<typeof openai.chat.completions.create>>;
let functionRanCounter = 0;
do {
answer = await openai.chat.completions.create({
...config.chatCompletionParams,
messages: OpenAImessages,
// FIXME: don't use new instance of FunctionManager
tools: new FunctionManager().getToolsForOpenAi(),
});
functionRanCounter += answer.choices[0].message?.tool_calls?.length ?? 0;
logUsedTokens(answer, message, ++functionRanCounter);
generatedMessage = answer.choices[0].message;
if (!generatedMessage) throw new Error("Empty message received");
// handle tool calls
if (generatedMessage.tool_calls !== undefined && generatedMessage.tool_calls.length > 0) {
OpenAImessages.push(generatedMessage);
// FIXME: don't use new instance of FunctionManager
OpenAImessages.push(...(await new FunctionManager().handleToolCalls(generatedMessage.tool_calls)));
}
} while (generatedMessage.tool_calls !== undefined && generatedMessage.tool_calls.length > 0);
return answer;
}

View file

@ -1,43 +1,46 @@
import { FunctionDefinition } from "openai/resources";
import {
ChatCompletionMessageParam
, ChatCompletionMessageToolCall
, ChatCompletionTool
} from "openai/resources/chat";
import { type FromSchema, type JSONSchema } from "json-schema-to-ts";
import { ChatCompletionFunctions, ChatCompletionRequestMessage, ChatCompletionRequestMessageFunctionCall } from "openai";
type OpenAIFunctionRequestData = (JSONSchema & {
type: "object"
});
import { config } from "./index";
type ChatCompletionToolDefinition = ChatCompletionTool;
type ChatCompletionToolCall = ChatCompletionMessageToolCall;
type parameterMap = {
string: string,
number: number,
};
type ChatCompletionFunctionDefinition = FunctionDefinition;
type nameTypeMap = {[name: string]: keyof parameterMap} | Record<string, never>;
type OpenAIFunctionRequestData<T extends nameTypeMap> = {
[name in keyof T]: T[name];
};
/**
* Represents the function that can be ran by the OpenAI model
*/
export interface OpenAIFunction<
T extends Readonly<OpenAIFunctionRequestData> = Readonly<OpenAIFunctionRequestData>
> {
export interface OpenAIFunction<T extends nameTypeMap = nameTypeMap> {
name: string,
description?: string,
parameters: T,
parameters: {
type: "object",
properties: T extends Record<string, never> ? Record<string, never> : {
[name in T[string]]: {
type: T[name],
description?: string,
}
},
required?: Array<keyof T>,
},
}
export abstract class OpenAIFunction<
T extends Readonly<OpenAIFunctionRequestData> = Readonly<OpenAIFunctionRequestData>
> {
getSettings(): ChatCompletionFunctionDefinition {
export abstract class OpenAIFunction<T extends nameTypeMap = nameTypeMap> {
getSettings(): ChatCompletionFunctions {
return {
name: this.name,
description: this.description,
parameters: this.parameters as Record<string, unknown>,
parameters: this.parameters,
};
}
abstract execute(data: FromSchema<T>): Promise<string>;
abstract execute(data: OpenAIFunctionRequestData<T>): string;
}
/*
@ -47,70 +50,60 @@ export default class FunctionManager {
store = new Map<string, OpenAIFunction>();
constructor() {
// TODO: import functions from functions directory
this.store.set("getTime", new GetTime());
}
public getTools(): ChatCompletionToolDefinition[] {
const rvalue: ChatCompletionToolDefinition[] = [];
public getFunctions(): ChatCompletionFunctions[] {
const rvalue: ChatCompletionFunctions[] = [];
for (const [, value] of this.store) {
rvalue.push({type: "function", function: value.getSettings()});
rvalue.push(value.getSettings());
}
return rvalue;
}
public getToolsForOpenAi(): ChatCompletionTool[] | undefined {
const rvalue = this.getTools();
return rvalue.length > 0 ? rvalue : undefined;
}
public handleFunction(request: ChatCompletionToolCall): Promise<ChatCompletionMessageParam> {
public handleFunction(request: ChatCompletionRequestMessageFunctionCall): ChatCompletionRequestMessage {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let parsedArguments: any;
const functionToRun = this.store.get(request.function.name);
const functionToRun = this.store.get(request.name ?? "");
// check if the function is registered
if (!functionToRun) {
return Promise.resolve({
return {
role: "system",
content: `Only use functions that were provided to you (response for tool call ID: ${request.id})`,
});
content: "Only use functions that were provided to you",
};
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
parsedArguments = JSON.parse(request.function.arguments);
parsedArguments = JSON.parse(request.arguments ?? "");
}
catch (e) {
console.error("Function arguments raw: " + request.function.arguments);
throw new Error(`Failed to parse the function JSON arguments when running function [${request.function.name}]`, {cause: e});
console.error("Function arguments raw: " + request.arguments);
throw new Error(`Failed to parse the function JSON arguments when running function [${request.name}]`, {cause: e});
}
// FIXME: Verify if the parsedArguments matches the requested function argument declaration.
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return functionToRun.execute(parsedArguments).then(content => {
return {
role: "tool",
tool_call_id: request.id,
content: content,
role: "function",
name: request.name,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
content: functionToRun.execute(parsedArguments),
};
});
}
}
public handleToolCall(call: ChatCompletionToolCall): Promise<ChatCompletionMessageParam> {
if (call.type === "function") {
return this.handleFunction(call);
}
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unsupported tool call type: ${call.type || "never"}`);
}
// buildins
public handleToolCalls(calls: ChatCompletionToolCall[]) {
const rvalue: Promise<ChatCompletionMessageParam>[] = [];
for (const call of calls) {
if (call.type === "function") {
rvalue.push(this.handleToolCall(call));
}
}
return Promise.all(rvalue);
class GetTime extends OpenAIFunction<Record<string, never>> {
name = "getTime";
description = "Gets current date and time with a timezone attached";
parameters = {
type: "object" as const,
properties: {} as Record<string, never>,
};
execute(): string {
return `${Intl.DateTimeFormat().resolvedOptions().timeZone}): ${new Date().toLocaleString("en-US", config.calendarParams)}`;
}
}

View file

@ -1,5 +1,5 @@
import DiscordApi from "discord.js";
import OpenAIApi from "openai";
import { Configuration as OpenAIApiConfiguration, OpenAIApi } from "openai";
import { PrismaClient } from "@prisma/client";
import Typescript from "typescript";
import fs from "node:fs";
@ -11,7 +11,6 @@ const discord = new DiscordApi.Client({
intents: [
DiscordApi.GatewayIntentBits.Guilds,
DiscordApi.GatewayIntentBits.GuildMessages,
DiscordApi.GatewayIntentBits.DirectMessages,
DiscordApi.GatewayIntentBits.MessageContent,
]
});
@ -24,17 +23,9 @@ function getConfig() {
["./config.ts"],
{outDir: "./dist"}
);
program.getSourceFiles()
.filter(e => {
if (!e.fileName.match(`^${process.cwd()}`)) return true;
if (e.fileName.match(`${process.cwd()}/node_modules`)) return false;
if (e.fileName.match(`${process.cwd()}/src/`)) return false;
return true;
})
.forEach(e => program.emit(e));
program.emit(program.getSourceFile("./config.ts"));
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-member-access
fileConfig = require("../config").default as IConfig;
fileConfig = require("./config").default as IConfig;
} catch (e) {
//FIXME: make errors more descriptive to the enduser
console.log(e);
@ -44,9 +35,9 @@ function getConfig() {
export const config: IConfigRequired = getConfig();
export const openai = new OpenAIApi({
export const openai = new OpenAIApi(new OpenAIApiConfiguration({
apiKey: config.tokens.OpenAI
});
}));
export const database = new PrismaClient();
@ -55,29 +46,13 @@ interactionManager.bindClient(discord);
discord.on("ready", event => {
console.log(`Connected to Discord as ${event.user.tag} (${event.user.id})`);
event.user.setPresence(config.status);
});
discord.on("messageCreate", message => {
if (message.author.bot) return;
if (!message.channel.isDMBased()) {
if (!message.mentions.has(message.client.user, { ignoreEveryone: true })) return;
}
if (!message.mentions.has(message.client.user)) return;
return queueRequest(message);
});
if (require.main === module) {
void discord.login(config.tokens.Discord);
process.on("SIGINT", () => {
console.log("got SIGINT, exiting");
//FIXME: finish executing requests then exit
discord.destroy()
.then(() => process.exit())
.catch((e) => {
console.error("Failed to gracefully exit");
console.error(e);
process.exit();
});
});
}
if (require.main === module) void discord.login(config.tokens.Discord);

View file

@ -34,9 +34,8 @@ export default class CommandManager {
interaction.reply({
embeds: [{
color: 0xff0000,
description: `Failed to perform interaction:\n\`\`${e}\`\``,
}],
ephemeral: true,
description: `Failed to perform interaction:\n\`${e}\``,
}]
}).catch(() => {/* NOTE: We're still logging the issue that happened to the console */});
console.error(`Failed to perform interaction: ${interaction.commandName}`);
console.error(e);

View file

@ -28,11 +28,11 @@ export default class Moderation {
}
}
const answer = await openai.moderations.create({
const answer = await openai.createModeration({
input: await formatRequestOrResponse(message),
});
const flagged = answer.results[0].flagged;
const flagged = answer.data.results[0].flagged;
this.cache.set(message.id, flagged);
// FIXME: These next 7 lines does not belong there and should be refactored out.
if (flagged) if (message instanceof Message) {

View file

@ -12,19 +12,16 @@ import { Usage } from "@prisma/client";
export default class tokenCount implements IQuota {
defaultQuota: number;
lookback: number;
requestTokenMultiplier: number;
responseTokenMultiplier: number;
considerInputTokensAsHalf: boolean;
constructor(
defaultQuota: number = 512 * 25,
lookback: number = 1000 * 60 * 60 * 24,
requestTokenMultiplier: number = 1,
responseTokenMultiplier: number = 1,
considerInputTokensAsHalf: boolean = true,
) {
this.defaultQuota = defaultQuota;
this.lookback = lookback;
this.requestTokenMultiplier = requestTokenMultiplier;
this.responseTokenMultiplier = responseTokenMultiplier;
this.considerInputTokensAsHalf = considerInputTokensAsHalf;
}
private getUserQuota(id: string) {
@ -54,11 +51,12 @@ export default class tokenCount implements IQuota {
}
}))._sum;
const usageRequest = usedTokens.usageRequest === null ? 0 : usedTokens.usageRequest;
const usageResponse = usedTokens.usageResponse === null ? 0 : usedTokens.usageResponse;
if (!usedTokens.usageRequest || !usedTokens.usageResponse) throw new Error("Null from a database!! (tokenCount Quota)");
const usedUnits = (() => {
return usageRequest * this.requestTokenMultiplier + usageResponse * this.responseTokenMultiplier;
if (this.considerInputTokensAsHalf)
return usedTokens.usageResponse + usedTokens.usageRequest / 2;
return usedTokens.usageResponse + usedTokens.usageRequest;
})();
if (userQuota?.vip) return this.createUserQuotaData(Infinity, usedUnits);
@ -75,13 +73,30 @@ export default class tokenCount implements IQuota {
* @returns promise of giving out the record
*/
findNthUsage(user: string, requestTimestamp: number, unitCount: number) {
return database.$queryRaw<Array<Usage & {usage: number | bigint}>>`
if (this.considerInputTokensAsHalf)
return database.$queryRaw<Array<Usage & {usage: number}>>`
SELECT t1.*, (
SELECT
SUM(
usageRequest * ${this.requestTokenMultiplier} +
usageResponse * ${this.responseTokenMultiplier}
) AS usage
SUM(usageResponse + usageRequest/2) AS usage
FROM \`usage\`
WHERE
user = ${user} AND
timestamp >= ${requestTimestamp - this.lookback} AND
timestamp <= t1.timestamp
) as usage
FROM
\`usage\` AS t1
WHERE
user = ${user} AND
timestamp >= ${requestTimestamp - this.lookback} AND
usage >= ${unitCount}
ORDER BY timestamp ASC
LIMIT 1
`;
return database.$queryRaw<Array<Usage & {usage: bigint}>>`
SELECT t1.*, (
SELECT
SUM(usageResponse + usageRequest) AS usage
FROM \`usage\`
WHERE
user = ${user} AND
@ -105,14 +120,14 @@ export default class tokenCount implements IQuota {
): Promise<userQuotaRecoveryData> {
const userId = typeof user ==="string" ? user : user.id;
const [userQuota, overUnitCountRecord] = await Promise.all([
const [userQuota, renameMebecause] = await Promise.all([
this.checkUser(userId, request),
this.findNthUsage(userId, request.createdTimestamp, unitCount)
]);
return {
...userQuota,
recoveryTimestamp: (overUnitCountRecord.at(0)?.timestamp.valueOf() ?? Infinity) + this.lookback,
recoveryTimestamp: (renameMebecause.at(0)?.timestamp.valueOf() ?? Infinity) + this.lookback,
};
}

View file

@ -0,0 +1,42 @@
// https://discordjs.guide/creating-your-bot/command-deployment.html#guild-commands
import { REST, RESTGetAPIOAuth2CurrentApplicationResult, RESTPostAPIApplicationCommandsJSONBody, Routes } from "discord.js";
import { config } from "../index";
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
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
post.push(new obj.default().toRESTPostApplicationCommands());
},
});
const rest = new REST().setToken(config.tokens.Discord);
(async () => {
const me = await rest.get(Routes.oauth2CurrentApplication()) as RESTGetAPIOAuth2CurrentApplicationResult;
if (guildId && guildId !== "") {
console.log(`Started refreshing ${post.length} application guild (${guildId}) commands.`);
await rest.put(
Routes.applicationGuildCommands(me.id, guildId),
{ body: post },
);
} else {
console.log(`Started refreshing ${post.length} application global commands.`);
await rest.put(
Routes.applicationCommands(me.id),
{ body: post },
);
}
console.log("Refreshed successfully");
})().catch( e => {
console.error(e);
});

View file

@ -1,4 +1,4 @@
import { ChatCompletionMessageParam as OpenAIMessage } from "openai/resources/chat";
import { ChatCompletionRequestMessage as OpenAIMessage } from "openai";
import { Collection, Message as DiscordMessage, InteractionResponse } from "discord.js";
import FoldToAscii from "fold-to-ascii";
@ -63,7 +63,7 @@ export function formatMessage(message: DiscordMessage): string {
* @param name the name to format
* @returns formatted name
*/
export function formatName(name: string): string {
function formatName(name: string): string {
// replace all characters to ascii equivelant
return FoldToAscii.foldReplacing(name)
// White spaces are not allowed
@ -88,8 +88,7 @@ function getAuthorUsername(message: DiscordMessage): string | undefined {
if (name.length >= 3) return name;
}
const name = formatName(message.author.username);
if (name.length > 0) return name;
return message.author.id;
return name;
}
/**

View file

@ -1,14 +1,13 @@
{
"include": [
"./src/**/*",
"./scripts/**/*"
"./src/**/*"
],
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"sourceMap": true,
"outDir": "./dist/",
"rootDir": "./",
"rootDir": "./src/",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,