Making a simple discord bot

With Java 17 and a nice library named JDA. Github repo is here.

Context

The other day I was enjoying myself with an image generating bot in a discord server of a creator I like. I got to wondering "how hard is it to make a discord bot anyway?"

Turns out it's not that hard at all.

The JDA library

I found this JDA library after a quick search and was reading through the examples late last night and got to thinking, hey. This would be a fun stream to do.

And it was! The, slightly editted, vod is up on youtube if you'd prefer to watch the bot come together live in a couple hours rather than read through the quick version. Fair warning that the vod includes some bugs that have been fixed for this post. Anyway, Here's a quick run down:

Setup

The easiest thing to do is to follow the instructions to create a ping pong bot as described on the JDA wiki. The only part that tripped me up was getting a bot token from the developer portal of discord because when you create a bot, they generate a token for you but never show it.

So the thing you need to do once you make the bot, is to immediately reset the token in order to get a chance to copy the value out to whereever you want to. Then you're ready to go! Almost. The tutorial doesn't include one very important part of the setup you'll need if you want your bot to read messages from users:

JDABuilder.enableIntents(GatewayIntent.MESSAGE_CONTENT)

This IS noted in the wiki but its unfortunate that it wasn't noted on the tutorial page since it threw a small wrench into an otherwise really smooth process. That said, once you've got the most basic of listeners up and working, then the Main method is pretty simple:

package space.peetseater.bot;

import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.requests.GatewayIntent;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

public class Main {
    public static void main(String[] args) throws IOException {
        String token = Files.readString(Paths.get(".").resolve("token.txt"));
        JDA api = JDABuilder
                .createDefault(token)
                .enableIntents(GatewayIntent.MESSAGE_CONTENT)
                .build();

        api.addEventListener(new TicTacBot());
    }
}

As you can see, I've got my token sitting on my hard drive, if you were deploying this onto a non-local server, you'd probably put it into an environmental variable or fetch it down from some vault somewhere as needed. But, hey, this is a simple bot that sits just in my server and plays tic tac toe, it doesn't need to scale.

Input parsing

The simplest thing to do as an interface is to setup a text string that people won't generally type and then use that as a flag to indicate we want the message to go to the bot specifically. Since this is a tic tac toe bot, the first thing that came to mind for me was:

!tt

Short for tic tac, and ! because no one in their right mind is going to start a sentence with a rightside up exclamation mark. An upside down one? Sure, yeah, normal even in some parts of the world. Anyway, I decided to go with some very basic and simple commands:

Pretty simple commands, and of course the hardest part is the last command because we need to make sure the user input is valid before trying to take any sort of action. Luckily for us, Java has a pretty standard way of parsing inputs: Scanner! The scanner can take in a pattern and then be used to pull out the captured groups for further tweaking:

package space.peetseater.bot.tictac;

import java.util.Scanner;
import java.util.regex.MatchResult;
import java.util.regex.Pattern;

public record Move(int x, int y, int value) {

    static final Pattern validMovePattern = Pattern.compile("([1-3])([1-3]) ([XxOo])");

    public final static Move INVALID = new Move(-1, -1, -1);

    public static Move parseMove(String noTT) {

        Scanner scanner = new Scanner(noTT);
        String found = scanner.findInLine(validMovePattern);
        if (found == null) {
            return INVALID;
        }

        MatchResult matchResult = scanner.match();

        String xString = matchResult.group(1);
        String yString = matchResult.group(2);
        String vString = matchResult.group(3);

        int x = Integer.parseInt(xString);
        int y = Integer.parseInt(yString);
        int v = switch (vString) {
            case "X", "x" -> TicTacGame.X;
            case "O", "o" -> TicTacGame.O;
            default -> TicTacGame.EMPTY;
        };

        return new Move(x, y, v);
    }
}

The Pattern class's expressions aren't quite the same as regular expressions. Though they look like it, it's got additional functionality like \p{javaLowerCase} and I know I've been bit by differences before, so just be mindful that if you're copying and pasting in a regex from some other system you used, that you might need to tweak things a little bit. Our pattern is pretty simple though, we allow for upper or lower case X or O and only allow 1 2 and 3 as the first two characters in the given string.

You might be thinking "wait, you said commands start with !tt", and you're right. But why should the move class care about the command prefixes? Let's keep the logic for the tic tac toe game pure and without any additional state from the discord part of things. This makes it simpler to unit test which is really helpful when you consider that I managed to write the end-game logic incorrectly at least twice while playing games with a friend.

Tic Tac logic

So, the pure tic tac toe game is just a simple wrapper around a 3x3 array of numbers that indicate which player has claimed a given space:

package space.peetseater.bot.tictac;

import java.util.HashMap;

public class TicTacGame {

    int[][] board = new int[][] {
            {0,0,0},
            {0,0,0},
            {0,0,0}
    };

    final static public int EMPTY = 0;
    final static public int X = 1;
    final static public int O = 2;
    private final HashMap values = new HashMap<>();

    public TicTacGame() {
        values.put(EMPTY, " ");
        values.put(X, "X");
        values.put(O, "O");
    }

    public boolean validMoveInputs(int x, int y, int value) {
        if (x < 1 || x > 3) return false;
        if (y < 1 || y > 3) return false;
        if (!values.containsKey(value)) return false;
        return true;
    }

    /** Check if a value can be placed at a given location
    * @param x value between 1 and 3 inclusive
    * @param y value between 1 and 3 inclusive
    * @param value 0 for EMPTY, 1 for X, 2 for O
    * @return false if the value cannot be placed, true otherwise
    * */
    public boolean canPlaceValue(int x, int y, int value) {
        if (!validMoveInputs(x, y, value)) return false;

        int xSubscript = x - 1;
        int ySubscript = y - 1;

        return board[xSubscript][ySubscript] == EMPTY;
    }

    /** Sets a value at a given location or does nothing.
    *  If a move is not valid, then this method returns silently.
    *
    * @param x value between 1 and 3 inclusive
    * @param y value between 1 and 3 inclusive
    * @param value 0 for EMPTY, 1 for X, 2 for O
    * */
    public void setBoxTo(int x, int y, int value) {
        if (!validMoveInputs(x,y,value)) return;

        int xSubscript = x - 1;
        int ySubscript = y - 1;

        board[xSubscript][ySubscript] = value;
    }

    public int getWinner() {
        for (int i = 0; i < 3; i++) {
            boolean sameInRow = board[i][0] == board[i][1] && board[i][0] == board[i][2];
            boolean sameInCol = board[0][i] == board[1][i] && board[0][i] == board[2][i];
            if (sameInRow && board[i][0] != EMPTY) {
                return board[i][0];
            }
            if (sameInCol && board[0][i] != EMPTY) {
                return board[0][i];
            }
        }

        if (board[0][0] == board[1][1] && board[0][0] == board[2][2]) {
            return board[0][0];
        }

        if (board[2][0] == board[1][1] && board[2][0] == board[0][2]) {
            return board[1][1];
        }

        return EMPTY;
    }

    public boolean isBoardFilled() {
        boolean filled = true;
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                filled = filled && board[i][j] != EMPTY;
            }
        }
        return filled;
    }

    public String boardString() {
        StringBuilder stringBuilder = new StringBuilder(12);
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                int v = board[i][j];
                stringBuilder.append('[').append(values.get(v)).append(']');
            }
            stringBuilder.append('\n');
        }
        return stringBuilder.toString();
    }

    public boolean hasWinner() {
        return getWinner() != EMPTY;
    }

    public String getWinnerName() {
        return values.get(getWinner());
    }
}

The one thing I'd like to call your attention to are the canPlaceValue and setBoxTo methods. Both of these present a 1 based indexing interface to the external world. While this leads to plenty of off by one errors for me while testing because it's natural to me to write 00 x instead of 11 x, if you think about our audience, most people on discord are going to think of the first row and column as having coordinates 1,1. This is such an odd thing for me, that I went ahead and added a proper docstring to the methods so that when I return to this code later on in life, I don't get confused.

It's nice to be nice to your future self. Give it a try sometime.

Parsing the discord message

The 3rd and last class of our super simple tic tac bot is a class that I'll probably refactor out into smaller bits, but for tutorial purposes, it's pretty simple to read through:

package space.peetseater.bot.tictac;

import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;

import java.util.regex.Matcher;

public class TicTacBot extends ListenerAdapter {

    TicTacGame ticTacGame;

    public TicTacBot() {
        ticTacGame = new TicTacGame();
    }

    public void sendMessage(MessageChannel channel, String message) {
        channel.sendMessage(message).queue();
    }

    @Override
    public void onMessageReceived(MessageReceivedEvent event) {
        if (event.getAuthor().isBot()) return;

        Message message = event.getMessage();
        String rawMessageContent = message.getContentRaw();

        if (!rawMessageContent.startsWith("!tt ")) {
            return;
        }

        MessageChannel channel = event.getChannel();
        String noTT = rawMessageContent.substring(4).trim();
        handleMessage(noTT, channel);
    }

    private void handleMessage(String command, MessageChannel channel) {
        if (command.equals("show")) {
            sendBoardStatus(channel);
            return;
        }

        if (command.equals("new")) {
            ticTacGame = new TicTacGame();
            sendMessage(channel, "New game started! Take your first move!");
            return;
        }

        if (command.equals("help")) {
            sendHelp(channel);
            return;
        }

        if (ticTacGame.hasWinner()) {
            sendMessage(channel, "The game has already ended, use `!tt new` to start again");
            return;
        }

        if (ticTacGame.isBoardFilled()) {
            sendMessage(channel, "The board is full! use !tt new to start fresh");
            return;
        }

        if (isValidMoveInput(command)) {
            Move move = Move.parseMove(command);
            if (!ticTacGame.canPlaceValue(move.x(), move.y(), move.value())) {
                sendMessage(channel, "You can't place that there, try again\n" + getFormattedBoardString());
                return;
            }

            ticTacGame.setBoxTo(move.x(), move.y(), move.value());

            if (ticTacGame.hasWinner()) {
                String winner = ticTacGame.getWinnerName();
                sendMessage(channel,"%s has won, use `!tt new` to start a new game\n%s".formatted(winner, getFormattedBoardString()));
            } else {
                sendBoardStatus(channel);
            }

            return;
        }

        sendMessage(channel, "I didn't understand your message, try !tt help");
    }

    public boolean isValidMoveInput(String raw) {
        Matcher matcher = Move.validMovePattern.matcher(raw);
        return matcher.matches();
    }

    private void sendHelp(MessageChannel channel) {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("Use `!tt` to send commands");
        stringBuilder.append("\n");
        stringBuilder.append("\t `!tt show` Will show you the current board");
        stringBuilder.append("\n");
        stringBuilder.append("\t `!tt new` Will reset the board to a clean slate");
        stringBuilder.append("\n");
        stringBuilder.append("\t `!tt help` Will show this text");
        stringBuilder.append("\n");
        stringBuilder.append("\t `!tt 12 X`\t Will set the first row and second column value to X");
        stringBuilder.append(", ");
        stringBuilder.append("you may use numbers `1`, `2`, and `3` to address rows and columns");
        stringBuilder.append(", ");
        stringBuilder.append("you may use `X`,`x`,`O`, or `o` for the values to set on the board");
        sendMessage(channel, stringBuilder.toString());
    }

    private void sendBoardStatus(MessageChannel channel) {
        sendMessage(channel, getFormattedBoardString());
    }

    private String getFormattedBoardString() {
        String currentBoard = ticTacGame.boardString();
        String codeTag = "```";
        return codeTag + currentBoard + codeTag;
    }
}

We've got a dependency through isValidMoveInput on the pattern we put into the parsing code we wrote, and this is very intentional because this should help keep the two in sync. If we validate the input with the same pattern as what's used to parse it, we shouldn't have any problems with invalid input.

The main workhorse is the handleMessage method. The codes a bit brittle in that we've got a very specific order of things, and we've got a million and a half returns in here to make sure we don't do any extra work if we can return earlier. This could probably all be refactored a bit, but this was enough to hook things up and confirm that I was able to play tic tac toe with a friend:

As you can read, the isBoardFilled related code didn't exist at the time of this screenshot. So, it, as all code, is always a work in progress but it's fun to fool around. That's really the thing to do, if you want to have some fun messing around with discord bots the JDA library seem like a really good place to do it. It was a really simple library to get up and started and let me get to work on the meat of the project pretty quickly. I hope this code helps you get some ideas for your own discord projects!

I know I've still got some ideas to do, so if the code on the github repository changes a lot, sorry about that! At the very least you'll have all the initial code right here in the blog post as well as what's in the repo's history.