5
\$\begingroup\$

I've created the beginnings of a Twitch IRC bot. As of current, it only has one command (!echo), though the infrastructure is (hopefully) there to library this code and build an actual bot on top of this base code.

This is my first time making a system with this much threaded and i/o operations in it, so I'd especially appreciate comments around those parts of the code. Other key points I'm looking at are: potential for further "good" abstraction of the Twitch IRC API; routines potentially located in the wrong class/package; places to use the Java8 Streams; extensibility of the command handler. And of course, naming and documentation improvements.

Uses JetBrains Nullability Annotations.

You can view the full bot on GitHub. You can view the Twitch IRC API on GitHub.

cad97.twitchapi.Constants

package cad97.twitchapi;

final class Constants {
    static final String HOST = "irc.chat.twitch.tv";
    static final int PORT = 6667;
    static final double RATE_LIMIT = 20d / 30d;
    static final double MOD_RATE_LIMIT = 100d / 30d;
    static final double INVERSE_RATE_LIMIT = 1d / RATE_LIMIT;
    static final double INVERSE_MOD_RATE_LIMIT = 1d / MOD_RATE_LIMIT;

    private Constants() {
        throw new UnsupportedOperationException("No Constants instance for you!");
    }
}

cad97.twitchapi.TwitchIRCSocket

package cad97.twitchapi;

import org.jetbrains.annotations.NotNull;

import java.io.*;
import java.net.Socket;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class TwitchIRCSocket implements Closeable {
    private final @NotNull Socket socket;
    private final @NotNull BufferedReader reader;
    private final @NotNull PrintWriter writer;

    private TwitchIRCSocket() throws IOException {
        socket = new Socket(Constants.HOST, Constants.PORT);
        reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()));
    }

    /**
     * A twitch connection over IRC with given methods to handle input and output.
     *
     * I/O is done raw to the Twitch IRC server.
     *
     * @param supplier A supplier of Strings to send messages.
     *                 Should block until a message is available to be sent.
     *
     * @param consumer A consumer of Strings to handle received messages.
     *                 Called whenever a message is received from the server.
     *                 Exception: PING PONG is handled for you.
     *
     * @param mod      If the bot is connecting as a mod.
     *                 Messages rate-limited to 20 messages / 30 seconds if false.
     *                 Messages rate-limited to 100 messages / 30 seconds if true.
     *
     * @throws IOException if an I/O connection occurs during connection to the server.
     */
    public TwitchIRCSocket(Supplier<String> supplier, Consumer<String> consumer, boolean mod) throws IOException {
        this();
        setReceiver(consumer);
        setSender(supplier);
    }

    private Thread receiver;

    private void setReceiver(@NotNull Consumer<@NotNull String> consumer) {
        System.out.println("Set receiver to " + consumer);
        if (receiver != null) receiver.interrupt();
        receiver = new Thread(() -> {
            try {
                while (!Thread.currentThread().isInterrupted()) {
                    receiveMessageInto(consumer);
                }
            } catch (IOException e) {
                System.err.println("Error during socket read");
                e.printStackTrace();
            }
        }, "Message Receiver");
        receiver.setDaemon(true);
        receiver.start();
        System.out.println("Message Receiver thread started");
    }

    private void receiveMessageInto(@NotNull Consumer<@NotNull String> consumer) throws IOException {
        String message = reader.readLine();
        System.out.println(message);
        if (message == null) throw new IOException("Socket closed");
        // PING PONG needs to be handled here for quick response times
        if (message.startsWith("PING ")) {
            sendMessageFrom(()->"PONG " + message.substring(5));
        } else {
            consumer.accept(message);
        }
    }

    private Thread sender;

    private void setSender(@NotNull Supplier<@NotNull String> supplier) {
        System.out.println("Set sender to " + supplier);
        if (sender != null) sender.interrupt();
        sender = new Thread(() -> {
            try {
                while (!Thread.currentThread().isInterrupted()) {
                    sendMessageFrom(supplier);
                }
            } catch (IOException e) {
                System.err.println("Error during socket write");
                e.printStackTrace();
            }
        }, "Message Sender");
        sender.setDaemon(true);
        sender.start();
        System.out.println("Message Sender thread started");
    }

    private void sendMessageFrom(@NotNull Supplier<@NotNull String> supplier) throws IOException {
        String message = supplier.get();
        if (message.endsWith("\n")) {
            System.out.print(">>>" + message);
            writer.print(message);
        } else {
            System.out.println(">>>" + message);
            writer.println(message);
        }
        writer.flush();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void close() throws IOException {
        socket.close();
        reader.close();
        writer.close();
        if (receiver != null) receiver.interrupt();
        if (sender != null) sender.interrupt();
    }

    private final static Pattern PRIVMSG = Pattern.compile("^:(\\w+)!\\1@\\1\\.tmi\\.twitch\\.tv PRIVMSG #(\\w+) :(.*)$");
    public static @NotNull Optional<@NotNull TwitchMessage> convertRawMessage(@NotNull String message) {
        Matcher match = PRIVMSG.matcher(message);
        if (!match.matches()) {
            return Optional.empty();
        } else {
            return Optional.of(new TwitchMessage(match.group(1), match.group(3)));
        }
    }
}

cad97.twitchapi.TwitchMessage

package cad97.twitchapi;

import org.jetbrains.annotations.NotNull;

public class TwitchMessage {
    public final @NotNull String user;
    public final @NotNull String message;

    TwitchMessage(@NotNull String user, @NotNull String message) {
        this.user = user;
        this.message = message;
    }
}

cad97.twitchbot.Main

package cad97.twitchbot;

import cad97.twitchapi.TwitchIRCSocket;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.jetbrains.annotations.NotNull;

public class Main extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(@NotNull Stage primaryStage) throws Exception {
        FXMLLoader fxmlLoader = new FXMLLoader();
        Parent root = fxmlLoader.load(getClass().getResourceAsStream("../../display.fxml"));
        final Controller controller = fxmlLoader.getController();

        primaryStage.setTitle("Twitch Bot");
        primaryStage.setScene(new Scene(root, 300, 275));
        primaryStage.show();

        Bot bot = new Bot(
                SensitiveConstants.NICKNAME,
                SensitiveConstants.CHANNEL,
                SensitiveConstants.OAUTH_TOKEN,
                controller::display
        );

        bot.registerCommand("!echo ", (message) -> message.message.substring(6));
        Thread botThread = new Thread(bot);
        botThread.setDaemon(true);
        botThread.start();

        TwitchIRCSocket ircSocket = new TwitchIRCSocket(bot::provideResponse, bot::receiveMessage, true);

        System.out.println("Main finished");
    }
}

cad97.twitchbot.Controller

package cad97.twitchbot;

import javafx.animation.AnimationTimer;
import javafx.fxml.FXML;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class Controller {

    @FXML
    TextArea textArea;

    public Controller() {
        new AnimationTimer() {
            @Override
            public void handle(long now) {
                if (!queue.isEmpty()) {
                    List<String> list = new ArrayList<>();
                    queue.drainTo(list);
                    for (String s : list) {
                        textArea.appendText(s);
                        if (!s.endsWith("\n")) {
                            textArea.appendText("\n");
                        }
                    }
                }
            }
        }.start();
    }

    private @NotNull BlockingQueue<@NotNull String> queue = new LinkedBlockingQueue<>();

    boolean display(@NotNull String s) {
        return queue.offer(s);
    }
}

cad97.twitchbot.bot

package cad97.twitchbot;

import cad97.twitchapi.TwitchMessage;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;

import java.util.HashMap;
import java.util.Optional;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.function.Consumer;
import java.util.function.Function;

import static cad97.twitchapi.TwitchIRCSocket.convertRawMessage;

class Bot implements Runnable {
    private final @NotNull String nickname;
    private final @NotNull String channel;
    private final @NotNull String oauthToken;
    private final @NotNull Consumer<String> display;

    Bot(@NotNull String nickname, @NotNull String channel, @NotNull String oauthToken, @NotNull Consumer<String> display) {
        this.nickname = nickname;
        this.channel = channel;
        this.oauthToken = oauthToken;
        this.display = display;
    }

    private @NotNull HashMap<@NotNull String, @NotNull Function<TwitchMessage, String>> commands = new HashMap<>();

    void registerCommand(@NotNull String command, @NotNull Function<TwitchMessage, String> response) {
        commands.put(command, response);
    }

    /**
     * {@inheritDoc}
     */
    public void run() {
        responses.add(String.format("PASS %1s", oauthToken));
        responses.add(String.format("NICK %1s", nickname));
        responses.add(String.format("JOIN #%1s", channel));

        while (!Thread.currentThread().isInterrupted()) {
            try {
                TwitchMessage message = messages.take();
                commands.keySet().stream()
                        .filter(message.message::startsWith)
                        .map(commands::get)
                        .map(command -> command.apply(message))
                        .map(this::formatMsg)
                        .forEach(responses::offer);
            } catch (InterruptedException e) {
                System.err.println("Error on bot thread");
                e.printStackTrace();
            }
        }
    }

    private @NotNull BlockingQueue<String> responses = new ArrayBlockingQueue<>(10);
    private @NotNull BlockingQueue<TwitchMessage> messages = new ArrayBlockingQueue<>(10);

    void receiveMessage(@NotNull String message) {
        Optional<TwitchMessage> twitchMessage = convertRawMessage(message);
        if (twitchMessage.isPresent()) {
            TwitchMessage tm = twitchMessage.get();
            System.out.println(String.format("[%1s] %2s", tm.user, tm.message));
            display.accept(String.format("[%1s] %2s", tm.user, tm.message));
            messages.offer(tm);
        } else {
            display.accept(message);
        }
    }

    @Contract("null -> null; !null -> !null")
    private String formatMsg(String msg) {
        if (msg == null) return null;
        return String.format("PRIVMSG #%1s :%2s", channel, msg);
    }

    String provideResponse() {
        try {
            return responses.take();
        } catch (InterruptedException e) {
            e.printStackTrace();
            return formatMsg("An internal error has occurred.");
        }
    }
}

cad97.twitchbot.SensitiveConstants

package cad97.twitchbot;

final class SensitiveConstants {
    static final String NICKNAME = "REDACTED";
    static final String CHANNEL = "REDACTED";
    static final String OAUTH_TOKEN = "oauth:REDACTED";
}
\$\endgroup\$
2
\$\begingroup\$
  • The annotations are nice, if a bit verbose. There are also some more (unpopular) annotations (like Lombok) that can remove even more boilerplate, but I guess that depends on personal taste and the contributors to a project.
  • I don't see much point with package-private visibility to be honest, similarly the private constructor in Constants is not helping the reader much.
  • The code doesn't look very testable, I'd suggest to move the inline anonymous classes into their own top-level ones and if possible to pass in objects in the constructor instead of constructing them there so you could instead pass in mock objects.
  • The comment for the mod parameter in TwitchIRCSocket is oddly specific and likely to be out of sync with the code very quickly. It's probably enough to link to the corresponding fields in the constants class or wherever the values are coming from (a future configuration file for example).
  • Splitting the two constructors like that looks super weird. I'd rather have a separate method to initialise the fields, or rather put it all in one constructor.
  • Use a logging framework sooner rather than later. System.out and friends will get quite old very quickly.
  • I'd suggest to move the fields together at the start or end of the class so it's tidier. If there are too many fields that's also an indicator that the class is growing too big.
  • In convertRawMessage the variable should be named matcher because it's a ... Matcher object.
  • In close I'd catch and throw away (or perhaps accumulate them) all the exceptions from the other close calls so that as many of them are done as possible.

Can't say anything about the UI part. The separation between the "raw" interface and having a separate, more customisable bot part is nice and the message-passing between the different actors does too. With a little bit less repetition and possibly splitting it up some more I think it's on the right way.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.