diff options
| author | plutorocks <> | 2026-02-24 19:49:08 -0500 |
|---|---|---|
| committer | plutorocks <> | 2026-02-24 19:49:08 -0500 |
| commit | d270f94eb002937677becaca638ec089f2294c42 (patch) | |
| tree | 8e8ae9e02324cc2a07240e2e6e4314b787089743 /src | |
| parent | d23825b05ec9cba95429c6ef693f469e99e37b36 (diff) | |
feat: add IRC
Diffstat (limited to 'src')
| -rw-r--r-- | src/main/java/dev/plutorocks/IrcClient.java | 199 | ||||
| -rw-r--r-- | src/main/java/dev/plutorocks/MinecraftIRC.java | 233 | ||||
| -rw-r--r-- | src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java | 61 | ||||
| -rw-r--r-- | src/main/java/dev/plutorocks/mixin/ExampleMixin.java | 15 | ||||
| -rw-r--r-- | src/main/resources/fabric.mod.json | 2 | ||||
| -rw-r--r-- | src/main/resources/minecraftirc.mixins.json | 2 |
6 files changed, 485 insertions, 27 deletions
diff --git a/src/main/java/dev/plutorocks/IrcClient.java b/src/main/java/dev/plutorocks/IrcClient.java new file mode 100644 index 0000000..f3abd40 --- /dev/null +++ b/src/main/java/dev/plutorocks/IrcClient.java @@ -0,0 +1,199 @@ +package dev.plutorocks; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import java.io.*; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +public class IrcClient { + + private final String host; + private final int port; + private final String channel; + private final String nick; + + private volatile boolean running = false; + private volatile boolean connected = false; + + private Socket socket; + private BufferedWriter writer; + + public IrcClient(String host, int port, String channel, String nick) { + this.host = host; + this.port = port; + this.channel = channel.startsWith("#") ? channel : "#" + channel; + this.nick = nick; + } + + /** + * start the IRC thread. + */ + public void connect() { + if (running) return; + running = true; + + Thread thread = new Thread(this::runLoop, "MinecraftIRC-Thread"); + thread.setDaemon(true); + thread.start(); + } + + public boolean isConnected() { + return connected; + } + + public void disconnect() { + running = false; + try { + if (socket != null && !socket.isClosed()) { + socket.close(); + } + } catch (IOException ignored) {} + connected = false; + } + + private void runLoop() { + try (Socket sock = new Socket(host, port); + BufferedReader reader = new BufferedReader( + new InputStreamReader(sock.getInputStream(), StandardCharsets.UTF_8)); + BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(sock.getOutputStream(), StandardCharsets.UTF_8))) { + + this.socket = sock; + this.writer = writer; + this.connected = true; + + sendRaw("NICK " + nick); + sendRaw("USER " + nick + " 0 * :" + nick); + + sendRaw("JOIN " + channel); + + sendClientChat( + Text.literal("[IRC] ").formatted(Formatting.AQUA) + .append(Text.literal("Connected to " + host + " " + channel + " as " + nick) + .formatted(Formatting.GRAY)) + ); + + String line; + while (running && (line = reader.readLine()) != null) { + handleLine(line); + } + + if (running) { + sendClientChat( + Text.literal("[IRC] ").formatted(Formatting.AQUA) + .append(Text.literal("Disconnected from server. Use /irc connect to reconnect.") + .formatted(Formatting.GRAY)) + ); + } + + } catch (IOException e) { + sendClientChat( + Text.literal("[IRC] ").formatted(Formatting.AQUA) + .append(Text.literal("Connection error: " + e.getMessage() + + " (use /irc connect to try again)") + .formatted(Formatting.GRAY)) + ); + } finally { + connected = false; + running = false; + } + } + + private void handleLine(String line) { + // respond to server PINGs + if (line.startsWith("PING")) { + String payload = line.length() > 5 ? line.substring(5) : ""; + sendRaw("PONG " + payload); + return; + } + + // only care about PRIVMSG + if (!line.contains(" PRIVMSG ")) { + return; + } + + int prefixEnd = line.indexOf(' '); + if (!line.startsWith(":") || prefixEnd <= 1) { + return; + } + + String prefix = line.substring(1, prefixEnd); + + String nick = prefix; + int bang = prefix.indexOf('!'); + if (bang != -1) { + nick = prefix.substring(0, bang); + } + + String[] split = line.split(" :", 2); + if (split.length < 2) { + return; + } + + String trailing = split[1]; + String commandPart = split[0]; + + String[] cmdParts = commandPart.split(" "); + if (cmdParts.length < 3) { + return; + } + + String target = cmdParts[2]; + + if (!target.equalsIgnoreCase(this.channel) + && !target.equalsIgnoreCase(this.nick)) { + return; + } + + MutableText prefixText = Text.literal("[IRC] ") + .formatted(Formatting.AQUA); + MutableText nickText = Text.literal("<" + nick + "> ") + .formatted(Formatting.WHITE); + MutableText msgText = Text.literal(trailing) + .formatted(Formatting.WHITE); + + sendClientChat(prefixText.append(nickText).append(msgText)); + } + + /** + * sends a message to the configured channel + */ + public void sendChannelMessage(String message) { + if (!connected) return; + sendRaw("PRIVMSG " + channel + " :" + message); + } + + /** + * low-level raw IRC send + */ + private synchronized void sendRaw(String line) { + if (writer == null) return; + try { + writer.write(line); + writer.write("\r\n"); + writer.flush(); + } catch (IOException e) { + sendClientChat( + Text.literal("[IRC] ").formatted(Formatting.AQUA) + .append(Text.literal("Send error: " + e.getMessage()) + .formatted(Formatting.GRAY)) + ); + disconnect(); + } + } + + private void sendClientChat(Text text) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) return; + + client.execute(() -> { + if (client.inGameHud != null) { + client.inGameHud.getChatHud().addMessage(text); + } + }); + } +}
\ No newline at end of file diff --git a/src/main/java/dev/plutorocks/MinecraftIRC.java b/src/main/java/dev/plutorocks/MinecraftIRC.java index a8aeed9..c802f20 100644 --- a/src/main/java/dev/plutorocks/MinecraftIRC.java +++ b/src/main/java/dev/plutorocks/MinecraftIRC.java @@ -1,24 +1,237 @@ package dev.plutorocks; -import net.fabricmc.api.ModInitializer; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.arguments.StringArgumentType; + +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.fabricmc.fabric.api.client.message.v1.ClientSendMessageEvents; +import net.fabricmc.loader.api.FabricLoader; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class MinecraftIRC implements ModInitializer { +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; + +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; + +public class MinecraftIRC implements ClientModInitializer { public static final String MOD_ID = "minecraftirc"; - // This logger is used to write text to the console and the log file. - // It is considered best practice to use your mod id as the logger's name. - // That way, it's clear which mod wrote info, warnings, and errors. public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + private static final Gson GSON = new Gson(); + private static final Path CONFIG_PATH = FabricLoader.getInstance().getConfigDir().resolve("minecraftirc.json"); + + public static String ircHost = "irc.libera.chat"; + public static final int ircPort = 6667; + public static String ircChannel = "#chat"; + public static String ircNick = "Player"; + + public static IrcClient IRC_CLIENT; + @Override - public void onInitialize() { - // This code runs as soon as Minecraft is in a mod-load-ready state. - // However, some things (like resources) may still be uninitialized. - // Proceed with mild caution. + public void onInitializeClient() { + loadConfig(); + + IRC_CLIENT = new IrcClient(ircHost, ircPort, ircChannel, ircNick); + IRC_CLIENT.connect(); + + ClientSendMessageEvents.CHAT.register(message -> { + if (message.startsWith("!")) { + if (IRC_CLIENT != null && IRC_CLIENT.isConnected()) { + IRC_CLIENT.sendChannelMessage(message.substring(1)); + } else { + sendIrcMessage("Not connected."); + } + } + }); + + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { + dispatcher.register( + literal("irc") + .then(literal("connect").executes(this::handleConnect)) + .then(literal("disconnect").executes(this::handleDisconnect)) + .then(literal("status").executes(this::handleStatus)) + .then(literal("host") + .then(argument("host", StringArgumentType.word()) + .executes(this::handleSetHost))) + .then(literal("channel") + .then(argument("channel", StringArgumentType.word()) + .executes(this::handleSetChannel))) + .then(literal("nick") + .then(argument("nick", StringArgumentType.word()) + .executes(this::handleSetNick))) + ); + }); + + LOGGER.info("MinecraftIRC loaded!"); + } + + private static void loadConfig() { + if (!Files.exists(CONFIG_PATH)) { + applyDefaultsFromSession(); + saveConfig(); + return; + } + + try (Reader reader = Files.newBufferedReader(CONFIG_PATH)) { + Config cfg = GSON.fromJson(reader, Config.class); + if (cfg == null) { + applyDefaultsFromSession(); + saveConfig(); + return; + } + + if (cfg.host != null && !cfg.host.isEmpty()) { + ircHost = cfg.host; + } else { + ircHost = "irc.libera.chat"; + } + + if (cfg.channel != null && !cfg.channel.isEmpty()) { + ircChannel = normalizeChannel(cfg.channel); + } else { + ircChannel = normalizeChannel("chat"); + } + + if (cfg.nick != null && !cfg.nick.isEmpty()) { + ircNick = cfg.nick; + } else { + ircNick = defaultNickFromSession(); + } + } catch (IOException | JsonSyntaxException e) { + LOGGER.warn("Failed to read MinecraftIRC config, using defaults", e); + applyDefaultsFromSession(); + } + } + + private static void saveConfig() { + Config cfg = new Config(); + cfg.host = ircHost; + cfg.channel = ircChannel; + cfg.nick = ircNick; + + try (Writer writer = Files.newBufferedWriter(CONFIG_PATH)) { + GSON.toJson(cfg, writer); + } catch (IOException e) { + LOGGER.warn("Failed to save MinecraftIRC config", e); + } + } + + private static void applyDefaultsFromSession() { + ircHost = "irc.libera.chat"; + ircChannel = normalizeChannel("chat"); + ircNick = defaultNickFromSession(); + } + + private static String defaultNickFromSession() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.getSession() != null) { + String name = client.getSession().getUsername(); + if (name != null && !name.isEmpty()) { + return name; + } + } + return "Player"; + } + + private int handleConnect(CommandContext<FabricClientCommandSource> ctx) { + if (IRC_CLIENT != null && IRC_CLIENT.isConnected()) { + IRC_CLIENT.disconnect(); + } + + IRC_CLIENT = new IrcClient(ircHost, ircPort, ircChannel, ircNick); + + sendCmdFeedback(ctx, "Connecting to " + ircHost + " " + ircChannel + + " as " + ircNick + " (port " + ircPort + ")..."); + IRC_CLIENT.connect(); + return 1; + } + + private int handleDisconnect(CommandContext<FabricClientCommandSource> ctx) { + if (IRC_CLIENT == null || !IRC_CLIENT.isConnected()) { + sendCmdFeedback(ctx, "Already disconnected."); + return 1; + } + + IRC_CLIENT.disconnect(); + sendCmdFeedback(ctx, "Disconnected."); + return 1; + } + + private int handleStatus(CommandContext<FabricClientCommandSource> ctx) { + String state = (IRC_CLIENT != null && IRC_CLIENT.isConnected()) ? "Connected" : "Not connected"; + sendCmdFeedback(ctx, state + ". Host=" + ircHost + + " Channel=" + ircChannel + " Nick=" + ircNick + + " Port=" + ircPort); + return 1; + } + + private int handleSetHost(CommandContext<FabricClientCommandSource> ctx) { + ircHost = StringArgumentType.getString(ctx, "host"); + saveConfig(); + sendCmdFeedback(ctx, "Host set to " + ircHost + ". Use /irc connect to apply."); + return 1; + } + + private static String normalizeChannel(String raw) { + raw = raw.trim(); + if (raw.isEmpty()) return "#chat"; + if (!raw.startsWith("#")) raw = "#" + raw; + return raw; + } + + private int handleSetChannel(CommandContext<FabricClientCommandSource> ctx) { + String raw = StringArgumentType.getString(ctx, "channel"); + ircChannel = normalizeChannel(raw); + saveConfig(); + sendCmdFeedback(ctx, "Channel set to " + ircChannel + ". Use /irc connect to apply."); + return 1; + } + + private int handleSetNick(CommandContext<FabricClientCommandSource> ctx) { + ircNick = StringArgumentType.getString(ctx, "nick"); + saveConfig(); + sendCmdFeedback(ctx, "Nick set to " + ircNick + ". Use /irc connect to apply."); + return 1; + } + + private void sendCmdFeedback(CommandContext<FabricClientCommandSource> ctx, String grayMessage) { + MutableText prefix = Text.literal("[IRC] ").formatted(Formatting.AQUA); + MutableText body = Text.literal(grayMessage).formatted(Formatting.GRAY); + ctx.getSource().sendFeedback(prefix.append(body)); + } + + private void sendIrcMessage(String grayMessage) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null) return; + + client.execute(() -> { + if (client.inGameHud != null) { + MutableText prefix = Text.literal("[IRC] ").formatted(Formatting.AQUA); + MutableText body = Text.literal(grayMessage).formatted(Formatting.GRAY); + client.inGameHud.getChatHud().addMessage(prefix.append(body)); + } + }); + } - LOGGER.info("Hello Fabric world!"); + private static class Config { + String host; + String channel; + String nick; } }
\ No newline at end of file diff --git a/src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java b/src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java new file mode 100644 index 0000000..6bc044d --- /dev/null +++ b/src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java @@ -0,0 +1,61 @@ +package dev.plutorocks.mixin; + +import dev.plutorocks.MinecraftIRC; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.ChatScreen; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ChatScreen.class) +public abstract class ChatScreenMixin { + + @Inject(method = "sendMessage", at = @At("HEAD"), cancellable = true) + private void minecraftirc$interceptIrcMessages(String chatText, boolean addToHistory, CallbackInfo ci) { + if (chatText == null) { + return; + } + + String trimmed = chatText.trim(); + if (!trimmed.startsWith("!")) { + return; + } + + String ircMessage = trimmed.substring(1); + + MinecraftClient client = MinecraftClient.getInstance(); + + if (MinecraftIRC.IRC_CLIENT != null && MinecraftIRC.IRC_CLIENT.isConnected()) { + if (!ircMessage.isEmpty()) { + MinecraftIRC.IRC_CLIENT.sendChannelMessage(ircMessage); + } + + if (client != null && client.inGameHud != null) { + MutableText prefix = Text.literal("[IRC] ").formatted(Formatting.AQUA); + MutableText nick = Text.literal("<" + MinecraftIRC.ircNick + "> ") + .formatted(Formatting.WHITE); + MutableText body = Text.literal(ircMessage) + .formatted(Formatting.GRAY); + + client.inGameHud.getChatHud().addMessage( + prefix.append(nick).append(body) + ); + } + } else { + if (client != null && client.inGameHud != null) { + MutableText prefix = Text.literal("[IRC] ").formatted(Formatting.AQUA); + MutableText body = Text.literal("Not connected.") + .formatted(Formatting.GRAY); + client.inGameHud.getChatHud().addMessage( + prefix.append(body) + ); + } + } + + ci.cancel(); + } +}
\ No newline at end of file diff --git a/src/main/java/dev/plutorocks/mixin/ExampleMixin.java b/src/main/java/dev/plutorocks/mixin/ExampleMixin.java deleted file mode 100644 index da1eff9..0000000 --- a/src/main/java/dev/plutorocks/mixin/ExampleMixin.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.plutorocks.mixin; - -import net.minecraft.server.MinecraftServer; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; - -@Mixin(MinecraftServer.class) -public class ExampleMixin { - @Inject(at = @At("HEAD"), method = "loadWorld") - private void init(CallbackInfo info) { - // This code is injected into the start of MinecraftServer.loadWorld()V - } -}
\ No newline at end of file diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 3bc9b56..c326293 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -15,7 +15,7 @@ "icon": "assets/minecraftirc/icon.png", "environment": "*", "entrypoints": { - "main": [ + "client": [ "dev.plutorocks.MinecraftIRC" ] }, diff --git a/src/main/resources/minecraftirc.mixins.json b/src/main/resources/minecraftirc.mixins.json index da52128..fc6e0cd 100644 --- a/src/main/resources/minecraftirc.mixins.json +++ b/src/main/resources/minecraftirc.mixins.json @@ -3,7 +3,7 @@ "package": "dev.plutorocks.mixin", "compatibilityLevel": "JAVA_21", "mixins": [ - "ExampleMixin" + "ChatScreenMixin" ], "injectors": { "defaultRequire": 1 |
