DEV Community

Cover image for How to create a dynamic AI Discord bot with TypeScript
clxrity
clxrity

Posted on

How to create a dynamic AI Discord bot with TypeScript

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


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):
pnpm init
Enter fullscreen mode Exit fullscreen mode
  • Install the dependencies and dev dependencies we'll be using to get started:
pnpm add -D typescript ts-node
Enter fullscreen mode Exit fullscreen mode
pnpm add discord.js nodemon dotenv mongoose openai
Enter fullscreen mode Exit fullscreen mode
  • Now let's set this up as a typescript project:
tsc --init
Enter fullscreen mode Exit fullscreen mode
  • And make sure it has our specific configurations:
{
  "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"
  ]
}
Enter fullscreen mode Exit fullscreen mode
  • And let's add these scripts to our package.json so we can run the project:
"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"
  },
Enter fullscreen mode Exit fullscreen mode
  • Time to create a ~/src/index.ts file and test that our project runs properly:
console.log("Hello World");
Enter fullscreen mode Exit fullscreen mode
  • 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
  • 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
  • Create a new API key and add it to your .env
  • Then copy your organization ID and add it as well (found here

MongoDB

  • Navigate to MongoDB Cloud
  • 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:

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;
Enter fullscreen mode Exit fullscreen mode

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.

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;
Enter fullscreen mode Exit fullscreen mode

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

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;
Enter fullscreen mode Exit fullscreen mode

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:

client.on("ready", () => {};
Enter fullscreen mode Exit fullscreen mode

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:
import "colors";
import { Client } from "discord.js";

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

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:
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
Enter fullscreen mode Exit fullscreen mode
  • 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
import "colors";
import db from "../../lib/db";

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

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

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
};
Enter fullscreen mode Exit fullscreen mode

Command type

Suppose we want our commands to look like:

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;
Enter fullscreen mode Exit fullscreen mode

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

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>;
}
Enter fullscreen mode Exit fullscreen mode

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

commandCompare()
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;
Enter fullscreen mode Exit fullscreen mode

Validations (interactionCreate)

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

eventName === "validations" ? (eventName = "interactionCreate") : eventName;
Enter fullscreen mode Exit fullscreen mode

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.

validations
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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register commands

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

registerCommands()
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);
    }
}
Enter fullscreen mode Exit fullscreen mode


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
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;
Enter fullscreen mode Exit fullscreen mode

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

commands registered

And if I run the command:

ping command

We're ready to start implementing the key features!


AI (OpenAI)

  • Create a file inside your lib/ directory to hold your OpenAI object:
import { OpenAI } from "openai";

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

export default openai;
Enter fullscreen mode Exit fullscreen mode
  • Create another file for your OpenAI query:

query()
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;
Enter fullscreen mode Exit fullscreen mode

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

/ai
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;
Enter fullscreen mode Exit fullscreen mode

And as you can see, this should work for you with absolutely no errors:

ai command gif

Mongoose

When setting up the query() from before, we manually passed in certain options like:

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.

models/guild.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);
Enter fullscreen mode Exit fullscreen mode

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:

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();
    }
Enter fullscreen mode Exit fullscreen mode

Then use those values within the chat completion function:

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
    })
Enter fullscreen mode Exit fullscreen mode

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
userPermissions: [PermissionsBitField.Flags.Administrator]
Enter fullscreen mode Exit fullscreen mode
  • Has sub commands:
.addSubcommand((sub) => sub
            .setName("reset")
            .setDescription("Reset your guild to default settings")
        )
Enter fullscreen mode Exit fullscreen mode

For configuration options, make sure to add the parameters you want to the ability to be altered:

  • presence_penalty
.addNumberOption((option) => option
                .setName("presence_penalty")
                .setDescription("How diverse the responses are")
                .setMinValue(-2)
                .setMaxValue(2)
            )
Enter fullscreen mode Exit fullscreen mode

run()

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

const { options, guildId } = interaction;

        const subCommand = options.getSubcommand(true);

        if (!["config", "view", "reset", "help"].includes(subCommand)) return;
Enter fullscreen mode Exit fullscreen mode
import Guild from "../../models/guild";
// ...

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

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

            await guildData.save();
        }
Enter fullscreen mode Exit fullscreen mode

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

await guildData.updateOne({ /* 
*/
})
Enter fullscreen mode Exit fullscreen mode

View the complete code for the way I set up my settings command here.

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.

The bot is live and running now on an AWS instance. There are steps listed in the repository's readme with how to go about getting your bot live at all times.

Feel free to make any issues and/or pull requests, or leave feedback with any thoughts!

Top comments (0)