Bubble - A  Not So Boring Bot

Bubble - A Not So Boring Bot

An open source discord bot

·

8 min read

Discord bots are truly amazing and can do incredible things! There are hundreds of bots available, with new ones being created all the time, each tailored to do amazing and unique things. From automating tasks to providing fun and games. And as a developer, I'm excited to build one. Introducing Bubble, a new Discord bot I’m currently developing. With Bubble, my goal is to create an amazing bot with all sorts of features and tools that will make interacting with Discord a thrilling experience. I am inviting the community to join me on this journey and contribute with feedback, ideas, and suggestions.

This is not any kind of tutorial on building a bot. Discord.js Guide is the best place to get started on building a bot.

This is the first blog of the series, and it's all about the basics of setting up the code for the bot. We'll also take a look at the first feature (MVP) of the Bubble.


The Base Setup

In the basic version of the bot, the goal is to register and add Bubble to a server. Let users interact with the bot using the Slash Commands, which are the first-class way of interacting directly with a bot.

Typescript + Nodejs

Starting with the basic setup of the project using Typescript and Node.js. Typescript will be used as the language of choice, as it allows for a more intuitive and clean codebase. Node.js will be used to write the code and handle the request/response interactions.

Linting

To maintain clean code, we should enforce a certain set of rules and standards for code formatting. So to enforce these rules and standards we are using ESLint with basic configuration and using prettier to format code using Eslint.

{
    "root": true,
    "parser": "@typescript-eslint/parser",
    "plugins": [
        "@typescript-eslint",
        "prettier",
        "no-loops"
    ],
    "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/eslint-recommended",
        "plugin:@typescript-eslint/recommended",
        "prettier"
    ],
    "rules": { 
        "no-console": 2,
        "prettier/prettier": 2,
        "no-loops/no-loops": 2,
        "@typescript-eslint/no-explicit-any": 2
    },
    "env": {
        "node": true
    }
}

To enforce these rules, we are using Husky to set up a pre-commit git hook to check for these rules and prevent the commit if not followed. But this can be very slow as the linter runs through all the files. To make it fast and easy, lint-staged is the package that runs the linter only against the files that are staged.

Setting Up a bot application

A new application has to be registered in the Discord Developer Portal before coding. And a bot has to be created within the application.

Application Name: Bubble App
Bot Name: Bubble
Get the Bubble Bot Token and stored it in the environment variables.

Bubble needs to be invited to the server. For a bot to be added to the server, we have to create an invite link which is in the format https://discord.com/api/oauth2/authorize?client_id=<CLIENT_ID>&permissions=<PERMISSIONS>&scope=bot%20applications.commands

Token
A token is essentially your bot's password; it's what your bot uses to log in to Discord.
Guild
The term "guild" is used by the Discord API and in discord.js to refer to a Discord server.
Client
An instance of the Discord.js library which allows your bot to interact with the Discord API.

Code Setup

Secrets:
To store all the secrets like Token, Guild Ids, Clients Id etc. we are using dotenv package.

src/commands
In this folder, each command has a file. Every command will have data that provides command definitions like name and description and function to respond to the interaction.

import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  Command
} from 'discord.js';

export const Ping: Command = {
  data: new SlashCommandBuilder()
    .setName('ping')
    .setDescription(
      'Replies with a Pong!. (Just to check if the bot is alive)'
    ),
  execute: async (interaction: ChatInputCommandInteraction) => {
    await interaction.reply('Pong!');
  }
};

discord.ts
This file contains the Discord bot instance and its configuration like registering Slash Commands to respond to certain events from the guilds. There are many events to explore, but for the base code we are working with ClientReady ( Once the Bot is ready ) and InteractionCreate ( Whenever discord emits an event like the message, slash command etc. ) events.

Creating an Instance

import { Client, GatewayIntentBits } from 'discord.js';

// Create a new client instance.
export const Bubble: Client = new Client({
  intents: [GatewayIntentBits.Guilds]
});

Bubble ( Which is an instance of Client ) is passed as a parameter to the listener function. So adding commands - a list of registered commands, as property to the Client Instance will allow to access the commands from anywhere.

Bubble.commands = new Collection();

Since the Client doesn't have the property, it throws a type error. To solve this

// discord.d.ts file

import { Message } from 'discord.js';

declare module 'discord.js' {
  export interface Client {
    commands: Collection<unknown, Command>;
  }

  export interface Command {
    data: SlashCommandBuilder;
    execute: (i: ChatInputCommandInteraction) => Promise<void>;                                                                
  }
}

Registering Commands
Adding commands to the collection and registering these commands using REST. According to Discordjs guide, Slash Commands should be registered only once and only updated when the definition is changed, as there is a daily limit on command creation. But for now, since the Bubble works on a specific guild and has very few commands, adding it to the Events.ClientReady event.

const CLIENT_ID = process.env.CLIENT_ID;
const GUILD_ID = process.env.GUILD_ID;
const rest = new REST({ version: '10' }).setToken(DISCORD_BOT_TOKEN);

// When the client is ready, run this code (only once)
Bubble.once(Events.ClientReady, async (c) => {
  /* eslint-disable-next-line no-console */
  console.log(`Ready! Logged in as ${c.user.tag}`);

  try {
    const commandList: SlashCommandBuilder[] = [];
    const commands = Commands as { [key: string]: Command };
    Object.keys(commands).map((name: string) => {
      Bubble.commands?.set(name.toLowerCase(), commands[name]);
      commandList.push(commands[name].data.toJSON());
    });

    if (CLIENT_ID && GUILD_ID) {
      // Currently registering commands only on a specific guild.
      await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), {
        body: commandList
      });
    }
  } catch (error) {
    /* eslint-disable-next-line no-console */
    console.log(error);
  }
});

Event handling
Whenever an interaction is received, Events.InteractionCreate is emitted, which calls an event handler with an Interaction parameter which also has Client with commands property to call registered commands.

Bubble.on(Events.InteractionCreate, async (interaction: Interaction) => {
  if (!interaction.isChatInputCommand()) return;

  const command = interaction.client.commands.get(interaction.commandName);

  if (!command) {
    /* eslint-disable-next-line no-console */
    console.log(`No command matching ${interaction.commandName} was found.`);
    return;
  }

  try {
    await command.execute(interaction);
  } catch (error) {
    /* eslint-disable-next-line no-console */
    console.error(error);
    const errMessage = {
      content: 'There was an error while executing this command!',
      ephemeral: true
    };
    if (interaction.replied || interaction.deferred) {
      await interaction.followUp(errMessage);
    } else {
      await interaction.reply(errMessage);
    }
  }
});

With ephemeral: true the reply is visible only to the user who called the command.

Index File
This is the entry point and we will log in to the Bot.

import dotenv from 'dotenv';
dotenv.config();

import { Bubble } from './discord';

const { DISCORD_BOT_TOKEN } = process.env;

(async () => {
  try {
    await Bubble.login(DISCORD_BOT_TOKEN);
  } catch (error) {
    /* eslint-disable-next-line no-console */
    console.log(error);
    process.exit(1);
  }
})();

Adding Scripts

Local Development: By using nodemon to look out for changes in the code and restart when any file is changed and ts-node for typescript.

Build: Remove the existing build ( using rimraf ) and compile the new code into the build folder.

Production: Run the build command and execute the entry point build/index.js

Linting and Fixing: Using Eslint to lint and fix all the typescript files.

// package.json
"scripts": {
    "dev": "npx nodemon",
    "build": "rimraf ./build && tsc",
    "start": "yarn run build && node build/index.js",
    "lint": "eslint . --ext .ts",
    "lint-and-fix": "eslint . --ext .ts --fix",
}

First Feature - Chat summarization

The first feature, which is still just an MVP, is Chat Summarization. With the help of Chat GPT, the idea is to summarize the last conversation in a channel for the user who missed the conversation.

MVP: When a user uses summarize Slash Command, Bubble fetches the last 50 messages and summarizes the conversation.

Slash Command Definition

import {
  SlashCommandBuilder,
  ChatInputCommandInteraction,
  Command,
  Message,
  TextChannel
} from 'discord.js';
import { SummarizeChat } from '../modules/ai/ask-ai';

export const Summarize: Command = {
  data: new SlashCommandBuilder()
    .setName('summarize')
    .setDescription('Summarizes last 50 messages.'),
  execute: async (interaction: ChatInputCommandInteraction) => {
    await interaction.deferReply();
    const channel = await interaction.client.channels.fetch(
      interaction.channelId
    );

    if (!channel || !(channel instanceof TextChannel)) {
      await interaction.editReply({
        content: 'Available only in Text Channels'
      });
      return;
    }

    const { lastMessageId } = channel;

    if (!channel || !lastMessageId) {
      await interaction.editReply({
        content: 'Unable to summarize.'
      });
      return;
    }

    let messages: Message[] = [];
    const fetchMessages = await channel.messages.fetch({
      limit: 50,
      before: lastMessageId
    });

    messages = messages.concat(Array.from(fetchMessages.values()));

    const ParsedMessages = messages
      .map((item) => `${item.author.username}: ${item.content}`)
      .reverse()
      .join('\n');

    const SummarizedMessage = await SummarizeChat(ParsedMessages);

    await interaction.editReply({
      content: SummarizedMessage
    });
  }
};

Summarize

import { Configuration, OpenAIApi } from 'openai';

const configuration = new Configuration({
  apiKey: process.env.OPEN_AI_KEY
});

const openai = new OpenAIApi(configuration);

export const SummarizeChat = async (messages: string): Promise<string> => {
  if (!configuration.apiKey) {
    return 'API key missing';
  }

  const prompt = messages + '\nTl;dr and who are talking?';

  const summarizedResponse = await openai.createCompletion({
    model: 'text-davinci-003',
    prompt,
    max_tokens: 250,
    temperature: 0.9,
    top_p: 0.8,
    presence_penalty: 1.84,
    frequency_penalty: 0.98
  });

  const summarizedContent =
    summarizedResponse.data.choices[0].text ?? "Sorry! Couldn't summarize.";

  return summarizedContent;
};

Now summarize is available in the Slash Commands on the server.


Summary

In this blog we looked at how to set up a Discorbot using Typescript and Node.js, set up a bot application and register commands, handle events and finally implement the first feature - Chat summarization. With the help of this blog series, you can have a look at the development process of Bubble Bot and also collaborate in the development process.


References

Code Setup: https://khalilstemmler.com/blogs/typescript/node-starter-project

Discord.js Guide: https://discordjs.guide


This is my first blog and the first blog in this new series Bubble - A Not So Boring Bot. The focus of this series will be to provide an in-depth look into the development process, from the ideation, planning and concept phases to the coding phase. By involving the community, the blog series will also be a platform for open and collaborative discussion and feedback on the development of Bubble Bot. The blog series will be regularly updated, with new posts focusing on different aspects of the development process.

Thank you for reading and your feedback is highly appreciated.