Compare commits
41 commits
feature/qu
...
main
Author | SHA1 | Date | |
---|---|---|---|
1b402c791c | |||
c4edf55f65 | |||
6f5f425166 | |||
b1e464fe50 | |||
6efb6e5876 | |||
9f5dfefb31 | |||
2fab1b1b42 | |||
482f72a4d1 | |||
d3567c3607 | |||
67a6e4d486 | |||
370b7623b5 | |||
b567e13f2a | |||
d9bee2dcf2 | |||
3c10f4ed6f | |||
63cb52e7f4 | |||
0e5c8d22cc | |||
91232e99a7 | |||
dc01146ee8 | |||
74359067d0 | |||
24e85f535a | |||
02a5b0f7b9 | |||
722862ded3 | |||
c911e567c6 | |||
6e1cb8c956 | |||
a186ba9e80 | |||
a05047ab7d | |||
4729f7f563 | |||
6a31473d22 | |||
b7f6a5fe91 | |||
ffa79e798e | |||
23ceca5cd3 | |||
8ed2e758f8 | |||
03a1c62cd5 | |||
6272c7f551 | |||
49b074f98e | |||
80f4f18eab | |||
552143e345 | |||
94992743e8 | |||
2629659ffc | |||
96dd7bce95 | |||
18646b9dc6 |
19 changed files with 1342 additions and 740 deletions
15
Dockerfile
15
Dockerfile
|
@ -12,7 +12,20 @@ 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/index.js"]
|
||||
CMD ["node", "dist/src/index.js"]
|
||||
STOPSIGNAL SIGINT
|
||||
|
|
1128
package-lock.json
generated
1128
package-lock.json
generated
File diff suppressed because it is too large
Load diff
23
package.json
23
package.json
|
@ -2,29 +2,30 @@
|
|||
"name": "gptcord",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"main": "./dist/index.js",
|
||||
"main": "./dist/src/index.js",
|
||||
"scripts": {
|
||||
"start": "tsc && node dist/index.js",
|
||||
"start": "tsc && node dist/src/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.0.0",
|
||||
"discord.js": "^14.8.0",
|
||||
"@prisma/client": "5.13.0",
|
||||
"discord.js": "14.14.1",
|
||||
"fold-to-ascii": "^5.0.1",
|
||||
"gpt-3-encoder": "^1.1.4",
|
||||
"openai": "^3.2.1",
|
||||
"require-directory": "^2.1.1"
|
||||
"openai": "^4.38.3",
|
||||
"require-directory": "^2.1.1",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fold-to-ascii": "^5.0.0",
|
||||
"@types/require-directory": "^2.1.2",
|
||||
"@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"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
|
66
scripts/pushCommands.ts
Normal file
66
scripts/pushCommands.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
// 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);
|
||||
});
|
78
shell.nix
Normal file
78
shell.nix
Normal file
|
@ -0,0 +1,78 @@
|
|||
{ 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"
|
||||
'';
|
||||
}
|
|
@ -1,23 +1,61 @@
|
|||
import { AutocompleteInteraction, PermissionsBitField } from "discord.js";
|
||||
import { RESTPostAPIApplicationCommandsJSONBody } from "discord.js";
|
||||
import { ApplicationCommandOption, ApplicationCommandType, ChatInputCommandInteraction, LocalizationMap, MessageInteraction, PermissionResolvable, UserSelectMenuInteraction } from "discord.js";
|
||||
import { APIApplicationCommandOption, ApplicationCommandType, ChatInputCommandInteraction, LocalizationMap, MessageInteraction, PermissionResolvable, UserSelectMenuInteraction } from "discord.js";
|
||||
|
||||
type InteractionTypeMap = {
|
||||
export type InteractionTypeMap = {
|
||||
// [CommandType]: [Interaction, Description]
|
||||
[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?: ApplicationCommandOption[];
|
||||
readonly options?: APIApplicationCommandOption[];
|
||||
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> {
|
||||
|
@ -25,7 +63,7 @@ abstract class Command<Type extends keyof InteractionTypeMap = ApplicationComman
|
|||
|
||||
autocomplete?(interaction: Type extends ApplicationCommandType.ChatInput ? AutocompleteInteraction : never ): Promise<void>;
|
||||
|
||||
toRESTPostApplicationCommands(): RESTPostAPIApplicationCommandsJSONBody {
|
||||
toRESTPostApplicationCommands(): FutureRESTPostAPIApplicationCommandsJSONBody {
|
||||
return {
|
||||
name: this.name,
|
||||
name_localizations: this.name_localizations,
|
||||
|
@ -36,6 +74,8 @@ 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
90
src/commands/ask.ts
Normal file
90
src/commands/ask.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
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);
|
||||
}
|
||||
}
|
|
@ -1,16 +1,20 @@
|
|||
import { ApplicationCommandType, ChatInputCommandInteraction, ApplicationCommandOption, ApplicationCommandOptionType, APIEmbedField } from "discord.js";
|
||||
import { ApplicationCommandType, ChatInputCommandInteraction, APIApplicationCommandOption, ApplicationCommandOptionType, APIEmbedField } from "discord.js";
|
||||
|
||||
import Command from "../command";
|
||||
import
|
||||
Command
|
||||
,{ApplicationIntegrationType
|
||||
, InteractionContextTypes
|
||||
} from "../command";
|
||||
import { config } from "../index";
|
||||
|
||||
export default class MyLimit extends Command implements Command {
|
||||
name = "check-limit";
|
||||
description = "Checks your limit and usage";
|
||||
name = "check-quota";
|
||||
description = "Checks your quota and usage";
|
||||
type = ApplicationCommandType.ChatInput;
|
||||
options: ApplicationCommandOption[] = [
|
||||
options: APIApplicationCommandOption[] = [
|
||||
{
|
||||
name: "recovery-for",
|
||||
description: "Calculate the limit recovery time for given message count (default 1)",
|
||||
description: "Get the recovery time for given quota units count (default: until can use the bot or 1)",
|
||||
type: ApplicationCommandOptionType.Integer,
|
||||
required: false,
|
||||
},
|
||||
|
@ -18,13 +22,34 @@ 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) ?? 1;
|
||||
let recoveryFor = interaction.options.getInteger("recovery-for", false);
|
||||
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);
|
26
src/commands/listen.ts
Normal file
26
src/commands/listen.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,23 +1,29 @@
|
|||
import { Message } from "discord.js";
|
||||
import {
|
||||
ChatCompletionRequestMessage as OpenAIMessage,
|
||||
CreateChatCompletionRequest as ChatCompletionRequestData,
|
||||
} from "openai";
|
||||
ActivityType
|
||||
, type PresenceStatusData
|
||||
, type PresenceData
|
||||
} from "discord.js";
|
||||
import {
|
||||
ChatCompletionMessageParam as OpenAIMessage,
|
||||
ChatCompletionCreateParamsNonStreaming as ChatCompletionRequestData,
|
||||
} from "openai/resources/chat";
|
||||
|
||||
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: Message): OpenAIMessage[];
|
||||
systemPrompt(context: apiRequest): OpenAIMessage[];
|
||||
/** OpenAI model config */
|
||||
readonly chatCompletionParams: Omit<ChatCompletionRequestData, "messages" | "function_call" | "functions" | "n">;
|
||||
readonly chatCompletionParams: Omit<ChatCompletionRequestData, "messages" | "function_call" | "tool_call" | "functions" | "n">;
|
||||
/** Limits for message selection */
|
||||
readonly readLimits: {
|
||||
/** Maximum message age to include (in miliseconds) */
|
||||
|
@ -40,6 +46,10 @@ 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];
|
||||
|
@ -51,16 +61,54 @@ 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") ?? "",
|
||||
|
@ -75,6 +123,16 @@ 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,
|
||||
|
|
205
src/execution.ts
205
src/execution.ts
|
@ -1,6 +1,10 @@
|
|||
import DiscordApi, { GuildTextBasedChannel, TextBasedChannel } from "discord.js";
|
||||
import { ChatCompletionRequestMessage, ChatCompletionResponseMessage } from "openai";
|
||||
import Axios from "axios";
|
||||
import {APIError as OpenAIError} from "openai";
|
||||
import {
|
||||
ChatCompletion,
|
||||
ChatCompletionMessage,
|
||||
ChatCompletionMessageParam
|
||||
} from "openai/resources/chat";
|
||||
|
||||
import { database, openai, config } from "./index";
|
||||
import Moderation from "./moderation";
|
||||
|
@ -69,7 +73,7 @@ export function getAuthor(request: apiRequest) {
|
|||
* @returns Promise of the done action
|
||||
*/
|
||||
function requestReply(
|
||||
request: RequestMessage,
|
||||
request: apiRequest,
|
||||
message: DiscordApi.MessageReplyOptions & DiscordApi.InteractionReplyOptions,
|
||||
// TODO: add support for these below
|
||||
replyOptions: DiscordApi.MessageReplyOptions = {},
|
||||
|
@ -168,29 +172,42 @@ 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
|
||||
* @param functionRan counter of how many function have been ran (to distinct records in database)
|
||||
*/
|
||||
function logUsedTokens(
|
||||
answer: Awaited<ReturnType<typeof openai.createChatCompletion>>,
|
||||
message: RequestMessage,
|
||||
functionRan: number,
|
||||
answer: ChatCompletion,
|
||||
message: apiRequest | undefined = undefined,
|
||||
functionRan: number = 0,
|
||||
) {
|
||||
const usage = answer.data.usage;
|
||||
const functionName = answer.data.choices[0].message?.function_call?.name;
|
||||
const usage = answer.usage;
|
||||
const functionNames =
|
||||
answer.choices[0].message.tool_calls?.map(
|
||||
v => v.type === "function" ? v.function.name : `[unknown type]`
|
||||
);
|
||||
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}${functionName ? " [Function: " + functionName + "]" : ""}`);
|
||||
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(", ") + "]" : ""}`);
|
||||
|
||||
database.usage.create({
|
||||
data: {
|
||||
timestamp: message.createdAt,
|
||||
user: BigInt(getAuthor(message).id),
|
||||
channel: BigInt(message.channelId),
|
||||
channel: BigInt(message.channelId ?? 0),
|
||||
guild: message.guildId ? BigInt(message.guildId) : null,
|
||||
usageRequest: usage.prompt_tokens,
|
||||
usageResponse: usage.completion_tokens,
|
||||
functionName: functionName ?? null,
|
||||
functionRan: functionName ? functionRan : 0,
|
||||
functionName: functionNames?.join(", ") ?? null,
|
||||
functionRan: functionRan,
|
||||
}
|
||||
}).catch((e => {
|
||||
console.error("Failed to push to a database");
|
||||
|
@ -206,8 +223,7 @@ function logUsedTokens(
|
|||
async function executeFromQueue(channel: string) {
|
||||
const channelQueue = channelsRunning.get(channel) as ChannelsRunningValue;
|
||||
const message = channelQueue.at(0) as RequestMessage;
|
||||
let functionRanCounter = 0;
|
||||
let OpenAImessages: ChatCompletionRequestMessage[] = [];
|
||||
let OpenAImessages: ChatCompletionMessageParam[] = [];
|
||||
|
||||
// ignore if we can't even send anything to reply
|
||||
if (!canReplyToRequest(message)) return;
|
||||
|
@ -234,75 +250,45 @@ async function executeFromQueue(channel: string) {
|
|||
});
|
||||
|
||||
OpenAImessages = toOpenAIMessages(messages.values());
|
||||
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);
|
||||
const answer = await executeChatCompletion(OpenAImessages, message);
|
||||
|
||||
channelQueue.stopTyping();
|
||||
|
||||
const answerContent = answer.data.choices[0].message?.content;
|
||||
const answerContent = answer.choices[0].message?.content;
|
||||
|
||||
if (answerContent === undefined || answerContent === "") {
|
||||
if (message instanceof DiscordApi.Message) message.react("😶").catch(() => {/* GRACEFAIL: It's okay if the bot won't reply */});
|
||||
await replyInMultiMessage(answerContent, message);
|
||||
} catch (e) {
|
||||
let errorText: string = "";
|
||||
channelQueue.stopTyping();
|
||||
if (typeof e !== "object") {
|
||||
console.error(`Error ocurred while handling chat completion request (${typeof e}):`);
|
||||
console.error(e);
|
||||
}
|
||||
else if (e === null) {
|
||||
console.error ("Error ocurred while handling chat completion request: null");
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
for (const i of answerMessagesContent) {
|
||||
const response = requestReply(message, {content: i}, {allowedMentions: { repliedUser: false }});
|
||||
|
||||
await response.then(rval => Moderation.checkMessageNoReturn(rval));
|
||||
if (e instanceof Error) {
|
||||
errorText = e.message;
|
||||
}
|
||||
else errorText = "";
|
||||
if (e instanceof OpenAIError && 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);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
channelQueue.stopTyping();
|
||||
console.error(`Error ocurred while handling chat completion request (${(e as object).constructor.name}):`);
|
||||
console.error(e);
|
||||
if (OpenAImessages.length !== 0) {
|
||||
console.error("Messages:");
|
||||
console.error(OpenAImessages);
|
||||
}
|
||||
|
||||
let errorText = "\n";
|
||||
|
||||
if (e instanceof Error) {
|
||||
errorText += e.message;
|
||||
}
|
||||
else errorText = "";
|
||||
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(
|
||||
|
@ -324,3 +310,70 @@ 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;
|
||||
}
|
||||
|
|
|
@ -1,46 +1,43 @@
|
|||
import { ChatCompletionFunctions, ChatCompletionRequestMessage, ChatCompletionRequestMessageFunctionCall } from "openai";
|
||||
import { FunctionDefinition } from "openai/resources";
|
||||
import {
|
||||
ChatCompletionMessageParam
|
||||
, ChatCompletionMessageToolCall
|
||||
, ChatCompletionTool
|
||||
} from "openai/resources/chat";
|
||||
import { type FromSchema, type JSONSchema } from "json-schema-to-ts";
|
||||
|
||||
import { config } from "./index";
|
||||
type OpenAIFunctionRequestData = (JSONSchema & {
|
||||
type: "object"
|
||||
});
|
||||
|
||||
type parameterMap = {
|
||||
string: string,
|
||||
number: number,
|
||||
};
|
||||
type ChatCompletionToolDefinition = ChatCompletionTool;
|
||||
type ChatCompletionToolCall = ChatCompletionMessageToolCall;
|
||||
|
||||
type nameTypeMap = {[name: string]: keyof parameterMap} | Record<string, never>;
|
||||
|
||||
type OpenAIFunctionRequestData<T extends nameTypeMap> = {
|
||||
[name in keyof T]: T[name];
|
||||
};
|
||||
type ChatCompletionFunctionDefinition = FunctionDefinition;
|
||||
|
||||
/**
|
||||
* Represents the function that can be ran by the OpenAI model
|
||||
*/
|
||||
export interface OpenAIFunction<T extends nameTypeMap = nameTypeMap> {
|
||||
export interface OpenAIFunction<
|
||||
T extends Readonly<OpenAIFunctionRequestData> = Readonly<OpenAIFunctionRequestData>
|
||||
> {
|
||||
name: string,
|
||||
description?: string,
|
||||
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>,
|
||||
},
|
||||
parameters: T,
|
||||
}
|
||||
|
||||
export abstract class OpenAIFunction<T extends nameTypeMap = nameTypeMap> {
|
||||
getSettings(): ChatCompletionFunctions {
|
||||
export abstract class OpenAIFunction<
|
||||
T extends Readonly<OpenAIFunctionRequestData> = Readonly<OpenAIFunctionRequestData>
|
||||
> {
|
||||
getSettings(): ChatCompletionFunctionDefinition {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parameters: this.parameters,
|
||||
parameters: this.parameters as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
abstract execute(data: OpenAIFunctionRequestData<T>): string;
|
||||
abstract execute(data: FromSchema<T>): Promise<string>;
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -50,60 +47,70 @@ export default class FunctionManager {
|
|||
store = new Map<string, OpenAIFunction>();
|
||||
|
||||
constructor() {
|
||||
this.store.set("getTime", new GetTime());
|
||||
// TODO: import functions from functions directory
|
||||
}
|
||||
|
||||
public getFunctions(): ChatCompletionFunctions[] {
|
||||
const rvalue: ChatCompletionFunctions[] = [];
|
||||
public getTools(): ChatCompletionToolDefinition[] {
|
||||
const rvalue: ChatCompletionToolDefinition[] = [];
|
||||
for (const [, value] of this.store) {
|
||||
rvalue.push(value.getSettings());
|
||||
rvalue.push({type: "function", function: value.getSettings()});
|
||||
}
|
||||
return rvalue;
|
||||
}
|
||||
|
||||
public handleFunction(request: ChatCompletionRequestMessageFunctionCall): ChatCompletionRequestMessage {
|
||||
public getToolsForOpenAi(): ChatCompletionTool[] | undefined {
|
||||
const rvalue = this.getTools();
|
||||
return rvalue.length > 0 ? rvalue : undefined;
|
||||
}
|
||||
|
||||
public handleFunction(request: ChatCompletionToolCall): Promise<ChatCompletionMessageParam> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let parsedArguments: any;
|
||||
|
||||
const functionToRun = this.store.get(request.name ?? "");
|
||||
const functionToRun = this.store.get(request.function.name);
|
||||
|
||||
// check if the function is registered
|
||||
if (!functionToRun) {
|
||||
return {
|
||||
return Promise.resolve({
|
||||
role: "system",
|
||||
content: "Only use functions that were provided to you",
|
||||
};
|
||||
content: `Only use functions that were provided to you (response for tool call ID: ${request.id})`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
parsedArguments = JSON.parse(request.arguments ?? "");
|
||||
parsedArguments = JSON.parse(request.function.arguments);
|
||||
}
|
||||
catch (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});
|
||||
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});
|
||||
}
|
||||
// FIXME: Verify if the parsedArguments matches the requested function argument declaration.
|
||||
return {
|
||||
role: "function",
|
||||
name: request.name,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
content: functionToRun.execute(parsedArguments),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// buildins
|
||||
|
||||
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)}`;
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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"}`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
39
src/index.ts
39
src/index.ts
|
@ -1,5 +1,5 @@
|
|||
import DiscordApi from "discord.js";
|
||||
import { Configuration as OpenAIApiConfiguration, OpenAIApi } from "openai";
|
||||
import OpenAIApi from "openai";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import Typescript from "typescript";
|
||||
import fs from "node:fs";
|
||||
|
@ -11,6 +11,7 @@ const discord = new DiscordApi.Client({
|
|||
intents: [
|
||||
DiscordApi.GatewayIntentBits.Guilds,
|
||||
DiscordApi.GatewayIntentBits.GuildMessages,
|
||||
DiscordApi.GatewayIntentBits.DirectMessages,
|
||||
DiscordApi.GatewayIntentBits.MessageContent,
|
||||
]
|
||||
});
|
||||
|
@ -23,9 +24,17 @@ function getConfig() {
|
|||
["./config.ts"],
|
||||
{outDir: "./dist"}
|
||||
);
|
||||
program.emit(program.getSourceFile("./config.ts"));
|
||||
|
||||
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));
|
||||
// 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);
|
||||
|
@ -35,9 +44,9 @@ function getConfig() {
|
|||
|
||||
export const config: IConfigRequired = getConfig();
|
||||
|
||||
export const openai = new OpenAIApi(new OpenAIApiConfiguration({
|
||||
export const openai = new OpenAIApi({
|
||||
apiKey: config.tokens.OpenAI
|
||||
}));
|
||||
});
|
||||
|
||||
export const database = new PrismaClient();
|
||||
|
||||
|
@ -46,13 +55,29 @@ 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.mentions.has(message.client.user)) return;
|
||||
if (!message.channel.isDMBased()) {
|
||||
if (!message.mentions.has(message.client.user, { ignoreEveryone: true })) return;
|
||||
}
|
||||
|
||||
return queueRequest(message);
|
||||
});
|
||||
|
||||
if (require.main === module) void discord.login(config.tokens.Discord);
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -34,8 +34,9 @@ export default class CommandManager {
|
|||
interaction.reply({
|
||||
embeds: [{
|
||||
color: 0xff0000,
|
||||
description: `Failed to perform interaction:\n\`${e}\``,
|
||||
}]
|
||||
description: `Failed to perform interaction:\n\`\`${e}\`\``,
|
||||
}],
|
||||
ephemeral: true,
|
||||
}).catch(() => {/* NOTE: We're still logging the issue that happened to the console */});
|
||||
console.error(`Failed to perform interaction: ${interaction.commandName}`);
|
||||
console.error(e);
|
||||
|
|
|
@ -28,11 +28,11 @@ export default class Moderation {
|
|||
}
|
||||
}
|
||||
|
||||
const answer = await openai.createModeration({
|
||||
const answer = await openai.moderations.create({
|
||||
input: await formatRequestOrResponse(message),
|
||||
});
|
||||
|
||||
const flagged = answer.data.results[0].flagged;
|
||||
const flagged = answer.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) {
|
||||
|
|
|
@ -12,16 +12,19 @@ import { Usage } from "@prisma/client";
|
|||
export default class tokenCount implements IQuota {
|
||||
defaultQuota: number;
|
||||
lookback: number;
|
||||
considerInputTokensAsHalf: boolean;
|
||||
requestTokenMultiplier: number;
|
||||
responseTokenMultiplier: number;
|
||||
|
||||
constructor(
|
||||
defaultQuota: number = 512 * 25,
|
||||
lookback: number = 1000 * 60 * 60 * 24,
|
||||
considerInputTokensAsHalf: boolean = true,
|
||||
requestTokenMultiplier: number = 1,
|
||||
responseTokenMultiplier: number = 1,
|
||||
) {
|
||||
this.defaultQuota = defaultQuota;
|
||||
this.lookback = lookback;
|
||||
this.considerInputTokensAsHalf = considerInputTokensAsHalf;
|
||||
this.requestTokenMultiplier = requestTokenMultiplier;
|
||||
this.responseTokenMultiplier = responseTokenMultiplier;
|
||||
}
|
||||
|
||||
private getUserQuota(id: string) {
|
||||
|
@ -51,12 +54,11 @@ export default class tokenCount implements IQuota {
|
|||
}
|
||||
}))._sum;
|
||||
|
||||
if (!usedTokens.usageRequest || !usedTokens.usageResponse) throw new Error("Null from a database!! (tokenCount Quota)");
|
||||
const usageRequest = usedTokens.usageRequest === null ? 0 : usedTokens.usageRequest;
|
||||
const usageResponse = usedTokens.usageResponse === null ? 0 : usedTokens.usageResponse;
|
||||
|
||||
const usedUnits = (() => {
|
||||
if (this.considerInputTokensAsHalf)
|
||||
return usedTokens.usageResponse + usedTokens.usageRequest / 2;
|
||||
return usedTokens.usageResponse + usedTokens.usageRequest;
|
||||
return usageRequest * this.requestTokenMultiplier + usageResponse * this.responseTokenMultiplier;
|
||||
})();
|
||||
|
||||
if (userQuota?.vip) return this.createUserQuotaData(Infinity, usedUnits);
|
||||
|
@ -73,30 +75,13 @@ export default class tokenCount implements IQuota {
|
|||
* @returns promise of giving out the record
|
||||
*/
|
||||
findNthUsage(user: string, requestTimestamp: number, unitCount: number) {
|
||||
if (this.considerInputTokensAsHalf)
|
||||
return database.$queryRaw<Array<Usage & {usage: number}>>`
|
||||
SELECT t1.*, (
|
||||
SELECT
|
||||
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}>>`
|
||||
return database.$queryRaw<Array<Usage & {usage: number | bigint}>>`
|
||||
SELECT t1.*, (
|
||||
SELECT
|
||||
SUM(usageResponse + usageRequest) AS usage
|
||||
SUM(
|
||||
usageRequest * ${this.requestTokenMultiplier} +
|
||||
usageResponse * ${this.responseTokenMultiplier}
|
||||
) AS usage
|
||||
FROM \`usage\`
|
||||
WHERE
|
||||
user = ${user} AND
|
||||
|
@ -120,14 +105,14 @@ export default class tokenCount implements IQuota {
|
|||
): Promise<userQuotaRecoveryData> {
|
||||
const userId = typeof user ==="string" ? user : user.id;
|
||||
|
||||
const [userQuota, renameMebecause] = await Promise.all([
|
||||
const [userQuota, overUnitCountRecord] = await Promise.all([
|
||||
this.checkUser(userId, request),
|
||||
this.findNthUsage(userId, request.createdTimestamp, unitCount)
|
||||
]);
|
||||
|
||||
return {
|
||||
...userQuota,
|
||||
recoveryTimestamp: (renameMebecause.at(0)?.timestamp.valueOf() ?? Infinity) + this.lookback,
|
||||
recoveryTimestamp: (overUnitCountRecord.at(0)?.timestamp.valueOf() ?? Infinity) + this.lookback,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
// 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);
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { ChatCompletionRequestMessage as OpenAIMessage } from "openai";
|
||||
import { ChatCompletionMessageParam as OpenAIMessage } from "openai/resources/chat";
|
||||
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
|
||||
*/
|
||||
function formatName(name: string): string {
|
||||
export function formatName(name: string): string {
|
||||
// replace all characters to ascii equivelant
|
||||
return FoldToAscii.foldReplacing(name)
|
||||
// White spaces are not allowed
|
||||
|
@ -88,7 +88,8 @@ function getAuthorUsername(message: DiscordMessage): string | undefined {
|
|||
if (name.length >= 3) return name;
|
||||
}
|
||||
const name = formatName(message.author.username);
|
||||
return name;
|
||||
if (name.length > 0) return name;
|
||||
return message.author.id;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
"./src/**/*",
|
||||
"./scripts/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist/",
|
||||
"rootDir": "./src/",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
|
|
Loading…
Reference in a new issue