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";
}