How to create a dynamic AI Discord bot with TypeScript
Tags: discord, typescript, openai, ai
Published: 2024-02-19 00:10:46

Learn how to create your own AI Discord bot (with command and event handling) that can be dynamically configurable through each guild.

### Concepts that will be explored throughout this tutorial
- [MongoDB](https://www.mongodb.com/)
- [TypeScript](https://www.typescriptlang.org/)
- [Discord.js](https://discordjs.guide/#before-you-begin)
- [OpenAI](https://openai.com/)

---
## Getting started


### Project initialization
- Create an empty folder for your project and initialize it (for this project, I'll be using `pnpm`, but feel free to use whatever you prefer):
```node
pnpm init
```
- Install the dependencies and dev dependencies we'll be using to get started:
```node
pnpm add -D typescript ts-node
```
```node
pnpm add discord.js nodemon dotenv mongoose openai
```

- Now let's set this up as a typescript project:
```node
tsc --init
```
- And make sure it has our specific configurations:
```json
{
  "compilerOptions": {
      "lib": [
          "ESNext"
      ],
      "module": "CommonJS",
      "moduleResolution": "node",
      "target": "ESNext",
      "outDir": "dist",
      "sourceMap": false,
      "resolveJsonModule": true,
      "esModuleInterop": true,
      "experimentalDecorators": true,
      "emitDecoratorMetadata": true,
      "allowSyntheticDefaultImports": true,
      "skipLibCheck": true,
      "skipDefaultLibCheck": true,
      "importHelpers": true,
  },
  "include": [
      "src/**/*",
      "environment.d.ts"
  ],
  "exclude": [
      "node_modules",
      "**/*.spec.ts"
  ]
}
```

- And let's add these scripts to our `package.json` so we can run the project:
```json
"scripts": {
    "start": "ts-node src/index.ts",
    "start:dev": "ts-node-dev src/index.ts",
    "start:prod": "node dist/index.js",
    "dev": "nodemon ./src/index.ts",
    "build": "tsc",
    "watch": "tsc -w"
  },
```

- Time to create a `~/src/index.ts` file and test that our project runs properly:

```ts
console.log("Hello World");
```

- If we run `pnpm dev` and see `Hello World` in the console, it seems like our project environment is ready!

---

### Getting our `.env` variables

#### Discord

- Navigate to the [Discord Developer Portal](https://discord.com/developers/applications)
- Create a new application
- Reset and copy the Bot's token and add it to your `.env`
- *Make sure to enable the necessary presence intents that you'd like your Discord bot to be able to access.*

#### OpenAI

- Navigate to your [OpenAI API Keys](https://platform.openai.com/api-keys)
- Create a new API key and add it to your `.env`
- Then copy your organization ID and add it as well (found [here](https://platform.openai.com/account/organization)

#### MongoDB

- Navigate to [MongoDB Cloud](https://cloud.mongodb.com)
- Create a new project and database then click **Connect**
 - Click **Drivers**
 - Copy your connection URI
  - Replace `<password>` with your password
- Add the URI to your `.env`

- Create a `~/src/lib/db.ts` file which will contain your MongoDB connection:

```ts
import "colors";
import mongoose from "mongoose";

const mongoURI = process.env.MONGO_URI;

const db = async () => {
    if (!mongoURI) {
        console.log(`[WARNING] Missing MONGO_URI environment variable!`.bgRed);
    }

    mongoose.set("strictQuery", true);

    try {
        if (await mongoose.connect(mongoURI)) {
            console.log(`[INFO] Connected to the database!`.bgCyan);
        }
    } catch (err) {
        console.log(`[ERROR] Couldn't establish a MongoDB connection!\n${err}`.red);
    }
}

export default db;
```

> **Optional**: install `colors` to add some color to your `console.log`'s

---

## Discord bot setup

We will need to set up a couple of folders and structures that will manage our Discord bot's events and commands.

### Utility

- Create a `utils/` folder within your `src/` directory which will contain multiple utility functions to help the management of our Discord bot.

We are going to create a couple utility files that will be useful within this project; but to start, we need a function that can read files within folders.

```ts
import fs from "fs";
import path from "path";

/* 
    this function will accept 2 (one is optional) parameters:
    (1) the directory of which to read the files
    (2) if the function should read folders only, which we'll set as false by default
*/

const getFiles = (directory: string, foldersOnly = false) => {
    let fileNames = [];

    const files = fs.readdirSync(directory, { withFileTypes: true });

    for (const file of files) {
        const filePath = path.join(directory, file.name);

        if (foldersOnly) {
            if (file.isDirectory()) {
                fileNames.push(filePath);
            }
        } else {
            if (file.isFile()) {
                fileNames.push(filePath);
            }
        }
    }

    return fileNames;
}

export default getFiles;
```

### Handler(s)

- Create a `/handlers/index.ts` within your `src/` directory:
 - For now, we will just add this `eventHandler()` function, but this can be expanded later for your needs.

> This function will accept a discord `Client` parameter which will then read and register events that will be located within an `events/` folder
```ts
import { Client } from "discord.js";
import path from "path";
import getFiles from "../utils/getFiles";

const eventHandler = (client: Client) => {
    const eventFolders = getFiles(path.join(__dirname, "..", "events"), true);

    for (const eventFolder of eventFolders) {
        const eventFiles = getFiles(eventFolder);

        let eventName: string;

        eventName = eventFolder.replace(/\\/g, '/').split("/").pop();

        eventName === "validations" ? (eventName = "interactionCreate") : eventName;

        client.on(eventName, async (args) => {
            for (const eventFile of eventFiles) {
                const eventFunction = require(eventFile);
                await eventFunction(client, args);
            }
        })
    }
}

export default eventHandler;
```

### Events

Now that we've established a function that can read and register events for the bot, let's set up some events we want to listen for.

Firstly we'll want our bot to listen for the `ready` event, if you've ever seen:
```js
client.on("ready", () => {};
```
This is exactly what we're setting up.

- Create a `ready/` folder within `events/`. Then inside this folder, we can put a file for each function we want to run when the bot is ready. 
 - To start, I want the bot to `console.log()` when it's ready, so I'm going to create a `consoleLog.ts` file:

```ts
import "colors";
import { Client } from "discord.js";

module.exports = (client: Client) => {
    console.log(`[INFO] ${client.user.username} is online!`.bgCyan);
}
```

#### IMPORTANT: 
> When *exporting* these functions so that they're registered, we need to use `module.exports` since our `eventHandler()` function uses `require()`

Before continuing, we should now test and see if our bot will listen for this event:

- Navigate to your `src/index.ts` file and register events to your bot:

```ts
import { config } from "dotenv";
import { Client, GatewayIntentBits } from "discord.js";
import eventHandler from "@/handlers";

config() // Load environment variables

const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.GuildMembers
    ] // Specify all the intents you wish your bot to access
});

eventHandler(client) // Register events

client.login(process.env.BOT_TOKEN); // Login to the bot
```

- If you run `pnpm dev` and see the console log, it looks like everything is working properly. 

Let's also connect to the database whenever the bot is ready.

- Add a `dbConnect.ts` file to `events/ready`

```ts
import "colors";
import db from "../../lib/db";

module.exports = async () => {
    await db().catch((err) => console.log(`[ERROR] Error connecting to database! \n${err}`.red));
}
```
---

Obviously, you're gonna want to have the ability to create/delete/edit commands. So, if we're gonna keep commands in, say, a `commands/` folder, let's create some utility functions that can gather those for us.

### Commands

- Create a file `utils/getCommands.ts` where we're going to have 2 essential functions `getApplicationCommands()` and `getLocalCommands()`
 - **`getApplicationCommands()`** this function will find the commands that are already registered to the bot.
 - **`getLocalCommands()`** this function will fetch the commands from the `commands/` folder.

#### Get commands

```ts
import { ApplicationCommandManager, Client, GuildApplicationCommandManager } from "discord.js";
import path from "path";
import getFiles from "./getFiles";


const getApplicationCommands = async (client: Client, guildId?: string) => {
    let applicationCommands: GuildApplicationCommandManager | ApplicationCommandManager;

    if (guildId) { // if registering to a specific guild
        const guild = await client.guilds.fetch(guildId);
        applicationCommands = guild.commands;
    } else {
        applicationCommands = client.application.commands;
    }

    await applicationCommands.fetch({
        guildId: guildId
    });

    return applicationCommands;
}

const getLocalCommands = (exceptions = []) => {
    let localCommands = [];

    const commandCategories = getFiles(path.join(__dirname, "..", "commands"), true);

    for (const commandCategory of commandCategories) {
        const commandFiles = getFiles(commandCategory);

        for (const commandFile of commandFiles) {
            const commandObject = require(commandFile);

            if (exceptions.includes(commandObject.name)) continue;
            localCommands.push(commandObject);
        }
    }

    return localCommands;
}

export {
    getApplicationCommands,
    getLocalCommands
};
```

#### Command type

Suppose we want our commands to look like:

```ts
import { PermissionsBitField, SlashCommandBuilder } from "discord.js";

const ping = {
    data: new SlashCommandBuilder()
        .setName("ping")
        .setDescription("Pong!")
        .addUserOption((option) => option
            .setName("user")
            .setDescription("The user you want to ping")
    ),
    userPermissions: [PermissionsBitField.Flags.SendMessages], // array of permissions the user needs to execute the command
    botPermissions: [PermissionsBitField.Flags.SendMessages], // array of permissions the bot needs to execute the command
    run: async (client, interaction) => {
        // run the command
    }
}

module.exports = ping;
```

Since we're using TypeScript, let's go ahead and create a type for our commands:

```ts
import { ChatInputCommandInteraction, Client, RESTPostAPIChatInputApplicationCommandsJSONBody, SlashCommandBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";

export type SlashCommand = {
    data: RESTPostAPIChatInputApplicationCommandsJSONBody | Omit<SlashCommandBuilder, "addSubcommandGroup" | "addSubcommand">
    | SlashCommandSubcommandsOnlyBuilder;
    userPermissions: Array<bigint>;
    botPermissions: Array<bigint>;
    run: (client: Client, interaction: ChatInputCommandInteraction) => Promise<any>;
}
```

Alright, we're *ALMOST* ready to create an event that'll handle registering commands...

But, unless you wanna re-register every command from the commands folder *every time* the bot is online, we'll need some sort of function that's going to compare the locally existing commands (`commands/`) to the commands that have been *already* registered to the bot.

#### Command compare

- Create a file within `utils/` that will hold our `commandCompare()` function

{% spoiler commandCompare() %} ```ts
import { ApplicationCommand } from "discord.js";
import { SlashCommand } from "./types";

const commandCompare = (existing: ApplicationCommand, local: SlashCommand) => {
    const changed = (a, b) => JSON.stringify(a) !== JSON.stringify(b);

    if (changed(existing.name, local.data.name) || changed(existing.description, local.data.description)) {
        return true;
    }

    function optionsArray(cmd) {
        const cleanObject = obj => {
            for (const key in obj) {
                if (typeof obj[key] === 'object') {
                    cleanObject(obj[key]);

                    if (!obj[key] || (Array.isArray(obj[key]) && obj[key].length === 0)) {
                        delete obj[key];
                    }
                } else if (obj[key] === undefined) {
                    delete obj[key];
                }
            }
        };

        const normalizedObject = (input) => {
            if (Array.isArray(input)) {
                return input.map((item) => normalizedObject(item));
            }

            const normalizedItem = {
                type: input.type,
                name: input.name,
                description: input.description,
                options: input.options ? normalizedObject(input.options) : undefined,
                required: input.required
            }

            return normalizedItem;
        }

        return (cmd.options || []).map((option) => {
            let cleanedOption = JSON.parse(JSON.stringify(option));
            cleanedOption.options ? (cleanedOption.options = normalizedObject(cleanedOption.options)) : (cleanedOption = normalizedObject(cleanedOption));
            cleanObject(cleanedOption);
            return {
                ...cleanedOption,
                choices: cleanedOption.choices ? JSON.stringify(cleanedOption.choices.map((c) => c.value)) : null
            }
        })
    }

    const optionsChanged = changed(optionsArray(existing), optionsArray(local.data));

    return optionsChanged;
}

export default commandCompare;
``` {% endspoiler %}

#### Validations (`interactionCreate`)

Circling back to the `eventHandler()`, do you remember this line:

```ts
eventName === "validations" ? (eventName = "interactionCreate") : eventName;
```

This was intended so we can **validate** the commands. We'll add a file within events `validations/command.ts` which will attempt to notify users if the bot and/or the user has insufficient permissions to use the command, or otherwise run the command.

{% spoiler validations %} ```ts
import "colors";
import { Client, ColorResolvable, CommandInteraction, EmbedBuilder, Colors } from "discord.js";
import { getLocalCommands } from "../../utils/getCommands";
import { SlashCommand } from "../../utils/types";

module.exports = async (client: Client, interaction: CommandInteraction) => {

    if (!interaction.isChatInputCommand()) return;

    const localCommands = getLocalCommands();
    const commandObject: SlashCommand = localCommands.find((cmd: SlashCommand) => cmd.data.name === interaction.commandName);

    if (!commandObject) return;

    const createEmbed = (color: string | ColorResolvable, description: string) => new EmbedBuilder()
        .setColor(color as ColorResolvable)
        .setDescription(description);
    
    for (const permission of commandObject.userPermissions || []) {
        if (!interaction.memberPermissions.has(permission)) {
            const embed = createEmbed(Colors.Red, "You do not have permission to execute this command!");

            return await interaction.reply({ embeds: [embed], ephemeral: true });
        }
    }

    const bot = interaction.guild.members.me;

    for (const permission of commandObject.botPermissions || []) {
        if (!bot.permissions.has(permission)) {
            const embed = createEmbed(Colors.Red, "I don't have permission to execute this command!");

            return await interaction.reply({ embeds: [embed], ephemeral: true });
        }
    }

    try {
        await commandObject.run(client, interaction);
    } catch (err) {
        console.log(`[ERROR] An error occured while validating commands!\n ${err}`.red);
        console.error(err);
    }
}
 ```{% endspoiler %}

#### Register commands

Now we can add an event within `events/ready/` that will register (add, delete, edit) the commands!

{% spoiler registerCommands() %} ```ts
import "colors";
import { Client } from "discord.js";
import commandCompare from "../../utils/commandCompare";
import { getApplicationCommands, getLocalCommands } from "../../utils/getCommands";

module.exports = async (client: Client) => {
    try {

        const [localCommands, applicationCommands] = await Promise.all([
            getLocalCommands(),
            getApplicationCommands(client)
        ]);

        for (const localCommand of localCommands) {
            const { data, deleted } = localCommand;
            const { name: commandName, description: commandDescription, options: commandOptions } = data;

            const existingCommand = applicationCommands.cache.find((cmd) => cmd.name === commandName);

            if (deleted) {
                if (existingCommand) {
                    await applicationCommands.delete(existingCommand.id);
                    console.log(`[COMMAND] Application command ${commandName} has been deleted!`.grey);
                } else {
                    console.log(`[COMMAND] Application command ${commandName} has been skipped!`.grey);
                }
            } else if (existingCommand) {
                if (commandCompare(existingCommand, localCommand)) {
                    await applicationCommands.edit(existingCommand.id, {
                        name: commandName, description: commandDescription, options: commandOptions
                    });
                    console.log(`[COMMAND] Application command ${commandName} has been edited!`.grey);
                }
            } else {
                await applicationCommands.create({
                    name: commandName, description: commandDescription, options: commandOptions
                });
                console.log(`[COMMAND] Application command ${commandName} has been registered!`.grey);
            }
        }

    } catch (err) {
        console.log(`[ERROR] There was an error inside the command registry!\n ${err}`.red);
    }
}
```{% endspoiler %}

---

#### Creating commands

It's time to create the first command to see if it registers when our bot is ready.

> ##### NOTE:
> You can create sub-folders for categories of commands.

##### `commands/misc/ping.ts`

```ts
import { SlashCommand } from "../../utils/types";
import { EmbedBuilder, SlashCommandBuilder, userMention, Colors } from "discord.js";

const ping: SlashCommand = {
    data: new SlashCommandBuilder()
        .setName("ping")
        .setDescription("Ping a user")
        .setDMPermission(false)
        .addUserOption((option) => option
            .setName("user")
            .setDescription("The user you wish to ping")
            .setRequired(true),
    ),
    userPermissions: [],
    botPermissions: [],
    run: async (client, interaction) => {
        const options = interaction.options;
        const target = options.getUser("user");

        const embed = new EmbedBuilder()
            .setDescription(userMention(target.id))
            .setColor(Colors.Default)
        
        return await interaction.reply({ embeds: [embed] });
    },
}

module.exports = ping;
```

You should be able to start up your bot and see:

![commands registered](https://i.gyazo.com/7c0028f5e5e9aa2bea55e60d7e72e037.png)

And if I run the command:

![ping command](https://i.gyazo.com/d7dbc45e43a8d2ad99930da21c7aec7c.png)

We're ready to start implementing the key features!

---

## AI (OpenAI)

- Create a file inside your `lib/` directory to hold your OpenAI object:

```ts
import { OpenAI } from "openai";

const openai = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
    organization: process.env.OPENAI_ORGANIZATION_ID,
});

export default openai;
```

- Create another file for your OpenAI query:

{% spoiler query() %} ```ts 
import openai from "./openai";

/*
    a sleep function to make sure the AI gets a good night's rest before it has to get back to work
*/
function sleep(ms: number) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

const query = async (prompt: string, guildId: string) => {
    
    /* 
        the `guildId` paramater will come in handy later when we want the bot to respond dynamically based on the guild's settings
    */
    
    if (!prompt || prompt.length < 1) return false;

    /*
        this variable directs the AI how to respond.
        it will be made dynamic later (along with all the other configurations)
    */
    const tempSystemRoleContent = "Respond to the given prompt in a funny and/or witty way."

    const res = await openai.chat.completions.create({
        model: "gpt-3.5-turbo",
        messages: [
            {
                role: "system",
                content: tempSystemRoleContent
            },
            {
                role: "user",
                content: prompt
            }
        ],
        temperature: 0.86,
        presence_penalty: 0
    })
        .then((res) => res.choices[0].message)
        .catch((err) => `Error with query!\n${err}`);
    
    await sleep(1000);

    if (typeof res === 'object') {
        return res.content;
    }

    return res;
}

export default query;
```{% endspoiler %}

- Create a command just to test that the AI is working, I'm just going to call it /ai

{% spoiler /ai %}```ts
import query from "../../lib/query";
import { SlashCommand } from "../../utils/types";
import { SlashCommandBuilder } from "discord.js";

const ai: SlashCommand = {
    data: new SlashCommandBuilder()
        .setName("ai")
        .setDescription("Say or ask something to an AI")
        .addStringOption((option) => option
            .setName("prompt")
            .setDescription("The prompt to give")
            .setRequired(true)
            .setMinLength(5)
            .setMaxLength(500)
    ),
    userPermissions: [],
    botPermissions: [],
    run: async (client, interaction) => {
        const { guildId } = interaction;

        if (!interaction.isCommand()) return;

        const prompt = interaction.options.getString("prompt");

        // defer the reply to give the openai query time
        await interaction.deferReply().catch(() => null)
        
        const response = await query(prompt, guildId);

        if (response === undefined || response === null || !response) {
            return await interaction.editReply({ content: "An error occured" })
        }
        if (interaction.replied) {
            return;
        }
        if (interaction.deferred) {
            return await interaction.editReply({ content: response });
        }

        return;
    }
}

module.exports = ai;
```{% endspoiler %}

And as you can see, this should work for you with absolutely no errors:
![ai command gif](https://i.gyazo.com/a17fb56d9c206b0a47b2cfc84b677a96.gif)
---

## Mongoose

When setting up the `query()` from before, we manually passed in certain options like:
- The [`temperature`](https://platform.openai.com/docs/guides/text-generation/how-should-i-set-the-temperature-parameter)
- The [`model`](https://platform.openai.com/docs/models/models)
- The [`presence_penalty`](https://platform.openai.com/docs/guides/text-generation/parameter-details)
- Last but not least, the **System role content**
 - *How you instruct the AI to respond*

Instead of having these variables set in stone, we're going to allow administrators of guilds to alter the settings. To do that, we need to be able to store a guild's settings within a database.

Firstly I'm going to envision the type of data to work with by creating a [model](https://mongoosejs.com/docs/api/model.html#Model()).

###### `models/guild.ts`

```ts
import { model, Schema } from "mongoose";

const guildSchema = new Schema({
    GuildID: String,
    SystemRoleContent: String,
    Temperature: Number,
    PresencePenalty: Number,
}, { strict: false });

export default model("guild", guildSchema);
```

Then I set up a configuration file which contains the default settings to use, while adding logic to the `query()` function to check for a guild's settings:

```ts
let guildData = await guild.findOne({ GuildID: guildId });

    if (!guildData) {
        guildData = new guild({
            GuildID: guildId,
            Temperature: config.openai.temperature,
            SystemRoleContent: config.openai.systemRoleContent,
            PresencePenalty: config.openai.presence_penalty,
            Model: config.openai.model,
        });

        await guildData.save();
    }
```

Then use those values within the chat completion function:

```ts
await openai.chat.completions.create({
        model: guildData.Model,
        messages: [
            {
                role: "system",
                content: guildData.SystemRoleContent
            },
            {
                role: "user",
                content: prompt
            }
        ],
        temperature: guildData.Temperature,
        presence_penalty: guildData.PresencePenalty
    })
```

---

Finally, create a command in which:

- Only admins can use
- Has sub commands:
 - `/settings view`
 - `/settings config`
 - `/settings reset`

## `/settings` command

- Only admins can use
```ts
userPermissions: [PermissionsBitField.Flags.Administrator]
```
- Has sub commands:

```ts
.addSubcommand((sub) => sub
            .setName("reset")
            .setDescription("Reset your guild to default settings")
        )
```
For configuration options, make sure to add the parameters you want to the ability to be altered:
- `presence_penalty`
```ts
.addNumberOption((option) => option
                .setName("presence_penalty")
                .setDescription("How diverse the responses are")
                .setMinValue(-2)
                .setMaxValue(2)
            )
```
#### run()

Get the sub command used & the guild, then fetch the guild's data model

```ts
const { options, guildId } = interaction;

        const subCommand = options.getSubcommand(true);

        if (!["config", "view", "reset", "help"].includes(subCommand)) return;
```
```ts
import Guild from "../../models/guild";
// ...

let guildData = await Guild.findOne({
            GuildID: guildId
        });

        if (!guildData) {
            guildData = new Guild({
                GuildID: guildId,
                // ...
            });

            await guildData.save();
        }
```

Then you can configure the guild's settings by utilizing `.updateOne()`:

```ts
await guildData.updateOne({ /* 
*/
})
```

View the complete code for the way I set up my settings command [here](https://github.com/clxrityy/ai-discord-bot/blob/main/src/commands/admin/settings.ts).

You now have a customizable AI bot!

---

## Ideas to expand
- Apply additional functionality to check for models/configure the model.
- Integrate into a website (nextjs?)
- Create a custom authentication page
- Set up logic so the default settings change based on prompts
- ...

---

Thank you for reading my first post on here! The full code can be found on my github [here](https://github.com/clxrityy/ai-discord-bot/tree/main).

The bot is live and running now on an AWS instance. There are steps listed in the repository's [**readme**](https://github.com/clxrityy/ai-discord-bot/#readme) with how to go about getting your bot live at all times.

Feel free to make any [issues](https://github.com/clxrityy/ai-discord-bot/issues) and/or [pull requests](https://github.com/clxrityy/ai-discord-bot/pulls), or leave feedback with any thoughts!