How to create a dynamic AI Discord bot with TypeScript
clxrity
Posted on February 19, 2024
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
- Install the dependencies and dev dependencies we'll be using to get started:
pnpm add -D typescript ts-node
pnpm add discord.js nodemon dotenv mongoose openai
- Now let's set this up as a typescript project:
tsc --init
- 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"
]
}
- 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"
},
- Time to create a
~/src/index.ts
file and test that our project runs properly:
console.log("Hello World");
- If we run
pnpm dev
and seeHello 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;
Optional: install
colors
to add some color to yourconsole.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 yoursrc/
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;
Handler(s)
- Create a
/handlers/index.ts
within yoursrc/
directory:- For now, we will just add this
eventHandler()
function, but this can be expanded later for your needs.
- For now, we will just add this
This function will accept a discord
Client
parameter which will then read and register events that will be located within anevents/
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;
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", () => {};
This is exactly what we're setting up.
- Create a
ready/
folder withinevents/
. 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 aconsoleLog.ts
file:
- To start, I want the bot to
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 oureventHandler()
function usesrequire()
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
- 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 toevents/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));
}
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 functionsgetApplicationCommands()
andgetLocalCommands()
-
getApplicationCommands()
this function will find the commands that are already registered to the bot. -
getLocalCommands()
this function will fetch the commands from thecommands/
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
};
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;
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>;
}
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 ourcommandCompare()
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;
Validations (interactionCreate
)
Circling back to the eventHandler()
, do you remember this line:
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.
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);
}
}
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);
}
}
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;
You should be able to start up your bot and see:
And if I run the 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;
- 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;
- 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;
And as you can see, this should work for you with absolutely no errors:
Mongoose
When setting up the query()
from before, we manually passed in certain options like:
- The
temperature
- The
model
- The
presence_penalty
- 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.
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);
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();
}
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
})
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]
- Has sub commands:
.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
.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
const { options, guildId } = interaction;
const subCommand = options.getSubcommand(true);
if (!["config", "view", "reset", "help"].includes(subCommand)) return;
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()
:
await guildData.updateOne({ /*
*/
})
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!
Posted on February 19, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.