summaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/dev/plutorocks/BridgeClient.java257
-rw-r--r--src/main/java/dev/plutorocks/PlutoBridge.java248
-rw-r--r--src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java103
-rw-r--r--src/main/resources/assets/minecraftirc/icon.pngbin0 -> 62171 bytes
-rw-r--r--src/main/resources/fabric.mod.json31
-rw-r--r--src/main/resources/minecraftirc.mixins.json14
6 files changed, 653 insertions, 0 deletions
diff --git a/src/main/java/dev/plutorocks/BridgeClient.java b/src/main/java/dev/plutorocks/BridgeClient.java
new file mode 100644
index 0000000..9126f20
--- /dev/null
+++ b/src/main/java/dev/plutorocks/BridgeClient.java
@@ -0,0 +1,257 @@
+package dev.plutorocks;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+
+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;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class BridgeClient {
+ private static final Logger LOGGER = LoggerFactory.getLogger("PlutoBridgeClient");
+ private static final Gson GSON = new Gson();
+
+ private final String host;
+ private final String token;
+ private volatile String nick;
+ private final String serverId = "default";
+
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private volatile boolean connected = false;
+
+ private WebSocket webSocket;
+
+ public BridgeClient(String host, String token, String nick) {
+ this.host = host;
+ this.token = token;
+ this.nick = (nick == null || nick.isEmpty()) ? "Player" : nick;
+ }
+
+ public boolean isConnected() {
+ return connected;
+ }
+
+ public void connect() {
+ if (running.getAndSet(true)) {
+ return; // already running
+ }
+
+ try {
+ String uriStr;
+
+ // if user already gave us a full WebSocket URL, trust it.
+ if (host.startsWith("ws://") || host.startsWith("wss://")) {
+ uriStr = host;
+ } else if (host.startsWith("http://") || host.startsWith("https://")) {
+ // convert http(s) -> ws(s)
+ boolean secure = host.startsWith("https://");
+ String rest = host.substring(secure ? "https://".length() : "http://".length());
+ uriStr = (secure ? "wss://" : "ws://") + rest;
+
+ // if there's no path after the host, add /ws
+ if (!rest.contains("/")) {
+ uriStr = uriStr + "/ws";
+ }
+ } else {
+ // bare hostname: assume HTTPS/WSS + /ws
+ // this matches typical reverse-proxy setup (TLS on 443).
+ uriStr = "wss://" + host;
+ if (!host.contains("/")) {
+ uriStr = uriStr + "/ws";
+ }
+ }
+
+ LOGGER.info("Connecting to bridge at {}", uriStr);
+
+ HttpClient client = HttpClient.newHttpClient();
+ WebSocket.Builder builder = client.newWebSocketBuilder();
+ CompletableFuture<WebSocket> future = builder.buildAsync(URI.create(uriStr), new BridgeListener());
+ future.whenComplete((ws, throwable) -> {
+ if (throwable != null) {
+ LOGGER.error("Failed to connect to bridge", throwable);
+ running.set(false);
+ String msg = throwable.getMessage();
+ if (msg == null) msg = throwable.getClass().getSimpleName();
+ sendClientChat("Failed to connect to bridge: " + msg);
+ return;
+ }
+ this.webSocket = ws;
+ sendAuth();
+ });
+ } catch (Exception e) {
+ LOGGER.error("Error starting WebSocket connection", e);
+ running.set(false);
+ sendClientChat("Bridge connection error: " + e.getMessage());
+ }
+ }
+
+ public void disconnect() {
+ running.set(false);
+ connected = false;
+ if (webSocket != null) {
+ try {
+ webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "client disconnect");
+ } catch (Exception ignored) {}
+ }
+ }
+
+ public void sendChatMessage(String message) {
+ if (!connected || webSocket == null) {
+ sendClientChat("Bridge is not connected.");
+ return;
+ }
+
+ JsonObject obj = new JsonObject();
+ obj.addProperty("type", "chat");
+ obj.addProperty("message", message);
+
+ String json = GSON.toJson(obj);
+ webSocket.sendText(json, true);
+ }
+
+ public void updateNick(String newNick) {
+ this.nick = newNick;
+ if (!connected || webSocket == null) {
+ return;
+ }
+
+ JsonObject obj = new JsonObject();
+ obj.addProperty("type", "nick");
+ obj.addProperty("nick", newNick);
+
+ String json = GSON.toJson(obj);
+ webSocket.sendText(json, true);
+ }
+
+ private void sendAuth() {
+ if (webSocket == null) {
+ return;
+ }
+
+ JsonObject auth = new JsonObject();
+ auth.addProperty("type", "auth");
+ auth.addProperty("token", token);
+ auth.addProperty("nick", nick);
+ auth.addProperty("serverId", serverId);
+ // playerUuid optional
+
+ String json = GSON.toJson(auth);
+ webSocket.sendText(json, true);
+ }
+
+ private void sendClientChat(String message) {
+ MinecraftClient client = MinecraftClient.getInstance();
+ if (client == null || client.inGameHud == null) {
+ return;
+ }
+ MutableText prefix = Text.literal("[Bridge] ").formatted(Formatting.AQUA);
+ MutableText body = Text.literal(message).formatted(Formatting.GRAY);
+ client.execute(() -> client.inGameHud.getChatHud().addMessage(prefix.append(body)));
+ }
+
+ private void handleIncomingChat(JsonObject obj) {
+ String fromNick = obj.has("fromNick") ? obj.get("fromNick").getAsString() : "?";
+ String msg = obj.has("message") ? obj.get("message").getAsString() : "";
+
+ MinecraftClient client = MinecraftClient.getInstance();
+ if (client == null || client.inGameHud == null) {
+ return;
+ }
+
+ MutableText line = Text.literal("[Bridge] ")
+ .formatted(Formatting.AQUA)
+ .append(Text.literal("<" + fromNick + "> ").formatted(Formatting.GRAY))
+ .append(Text.literal(msg).formatted(Formatting.WHITE));
+
+ client.execute(() -> client.inGameHud.getChatHud().addMessage(line));
+ }
+
+ private class BridgeListener implements WebSocket.Listener {
+
+ private final StringBuilder textBuffer = new StringBuilder();
+
+ @Override
+ public void onOpen(WebSocket webSocket) {
+ LOGGER.info("Bridge WebSocket opened");
+ connected = true;
+ sendClientChat("Connected to bridge.");
+ WebSocket.Listener.super.onOpen(webSocket);
+ }
+
+ @Override
+ public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
+ textBuffer.append(data);
+ if (last) {
+ String full = textBuffer.toString();
+ textBuffer.setLength(0);
+
+ try {
+ JsonObject obj = GSON.fromJson(full, JsonObject.class);
+ if (obj != null && obj.has("type")) {
+ String type = obj.get("type").getAsString();
+ if ("chat".equals(type)) {
+ handleIncomingChat(obj);
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.warn("Failed to parse bridge message: {}", full, e);
+ }
+ }
+ return WebSocket.Listener.super.onText(webSocket, data, last);
+ }
+
+ @Override
+ public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
+ LOGGER.info("Bridge WebSocket closed: {} {}", statusCode, reason);
+ connected = false;
+ running.set(false);
+
+ String reasonText = (reason == null ? "" : reason).toLowerCase();
+
+ // 1008 = Policy Violation -> we use this for "token revoked"
+ if (statusCode == 1008 && reasonText.contains("token revoked")) {
+ sendClientChat("Disconnected from bridge: token revoked by server.");
+ } else {
+ sendClientChat("Disconnected from bridge (" + statusCode + "): " + reason);
+ }
+
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public void onError(WebSocket webSocket, Throwable error) {
+ LOGGER.error("Bridge WebSocket error", error);
+ connected = false;
+ running.set(false);
+ sendClientChat("Bridge connection error: " + error.getMessage());
+ }
+
+ @Override
+ public CompletionStage<?> onBinary(WebSocket webSocket, java.nio.ByteBuffer data, boolean last) {
+ return WebSocket.Listener.super.onBinary(webSocket, data, last);
+ }
+
+ @Override
+ public CompletionStage<?> onPong(WebSocket webSocket, java.nio.ByteBuffer message) {
+ return WebSocket.Listener.super.onPong(webSocket, message);
+ }
+
+ @Override
+ public CompletionStage<?> onPing(WebSocket webSocket, java.nio.ByteBuffer message) {
+ webSocket.sendPong(java.nio.ByteBuffer.wrap("pong".getBytes(StandardCharsets.UTF_8)));
+ return WebSocket.Listener.super.onPing(webSocket, message);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/main/java/dev/plutorocks/PlutoBridge.java b/src/main/java/dev/plutorocks/PlutoBridge.java
new file mode 100644
index 0000000..aa3438f
--- /dev/null
+++ b/src/main/java/dev/plutorocks/PlutoBridge.java
@@ -0,0 +1,248 @@
+package dev.plutorocks;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import com.mojang.brigadier.arguments.StringArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+
+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.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;
+
+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 PlutoBridge implements ClientModInitializer {
+ public static final String MOD_ID = "PlutoBridge";
+ 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("bridge.json");
+
+ public static final int MAX_NICK_LEN = 15;
+ public static final int MAX_MESSAGE_LEN = 400; // must match server
+
+ public static String bridgeHost = "bridge.plutorocks.dev";
+ public static String bridgeToken = "";
+ public static String bridgeNick; // will be set from config/session
+
+ public static BridgeClient BRIDGE_CLIENT;
+
+ // false = normal chat default, true = bridge default
+ public static boolean bridgeChatDefault = false;
+
+ @Override
+ public void onInitializeClient() {
+ loadConfig();
+
+ // Auto-connect if we already have a token
+ if (bridgeToken != null && !bridgeToken.isEmpty()) {
+ BRIDGE_CLIENT = new BridgeClient(bridgeHost, bridgeToken, bridgeNick);
+ BRIDGE_CLIENT.connect();
+ }
+
+ ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> {
+ dispatcher.register(
+ literal("bridge")
+ .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("token")
+ .then(argument("token", StringArgumentType.word())
+ .executes(this::handleSetToken)))
+ .then(literal("nick")
+ .then(argument("nick", StringArgumentType.word())
+ .executes(this::handleSetNick)))
+ .then(literal("toggle")
+ .executes(this::handleToggle))
+ );
+ });
+
+ LOGGER.info("Bridge client 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()) {
+ bridgeHost = cfg.host;
+ } else {
+ bridgeHost = "bridge.plutorocks.dev";
+ }
+
+ if (cfg.token != null && !cfg.token.isEmpty()) {
+ bridgeToken = cfg.token;
+ } else {
+ bridgeToken = "";
+ }
+
+ if (cfg.nick != null && !cfg.nick.isEmpty()) {
+ bridgeNick = cfg.nick;
+ } else {
+ bridgeNick = defaultNickFromSession();
+ }
+ } catch (IOException | JsonSyntaxException e) {
+ LOGGER.warn("Failed to read bridge config, using defaults", e);
+ applyDefaultsFromSession();
+ }
+ }
+
+ private static void saveConfig() {
+ Config cfg = new Config();
+ cfg.host = bridgeHost;
+ cfg.token = bridgeToken;
+ cfg.nick = bridgeNick;
+
+ try (Writer writer = Files.newBufferedWriter(CONFIG_PATH)) {
+ GSON.toJson(cfg, writer);
+ } catch (IOException e) {
+ LOGGER.warn("Failed to save bridge config", e);
+ }
+ }
+
+ private static void applyDefaultsFromSession() {
+ bridgeHost = "bridge.plutorocks.dev";
+ bridgeToken = "";
+ bridgeNick = 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 (bridgeToken == null || bridgeToken.isEmpty()) {
+ sendCmdFeedback(ctx, "Token is empty. Set it with /bridge token <token> first.");
+ return 1;
+ }
+
+ if (BRIDGE_CLIENT != null && BRIDGE_CLIENT.isConnected()) {
+ sendCmdFeedback(ctx, "Already connected.");
+ return 1;
+ }
+
+ if (BRIDGE_CLIENT != null) {
+ BRIDGE_CLIENT.disconnect();
+ }
+
+ BRIDGE_CLIENT = new BridgeClient(bridgeHost, bridgeToken, bridgeNick);
+
+ sendCmdFeedback(ctx, "Connecting to " + bridgeHost + " as " + bridgeNick + "...");
+ BRIDGE_CLIENT.connect();
+ return 1;
+ }
+
+ private int handleDisconnect(CommandContext<FabricClientCommandSource> ctx) {
+ if (BRIDGE_CLIENT == null || !BRIDGE_CLIENT.isConnected()) {
+ sendCmdFeedback(ctx, "Already disconnected.");
+ return 1;
+ }
+
+ BRIDGE_CLIENT.disconnect();
+ sendCmdFeedback(ctx, "Disconnected.");
+ return 1;
+ }
+
+ private int handleStatus(CommandContext<FabricClientCommandSource> ctx) {
+ String state = (BRIDGE_CLIENT != null && BRIDGE_CLIENT.isConnected()) ? "Connected" : "Not connected";
+ sendCmdFeedback(ctx, state + ". Host=" + bridgeHost + " Nick=" + bridgeNick
+ + " Mode=" + (bridgeChatDefault ? "BRIDGE" : "CHAT"));
+ return 1;
+ }
+
+ private int handleToggle(CommandContext<FabricClientCommandSource> ctx) {
+ bridgeChatDefault = !bridgeChatDefault;
+
+ if (bridgeChatDefault) {
+ sendCmdFeedback(ctx, "Bridge mode ON. Messages go to the bridge by default; prefix with ! to send to normal chat.");
+ } else {
+ sendCmdFeedback(ctx, "Bridge mode OFF. Messages go to normal chat by default; prefix with ! to send to the bridge.");
+ }
+
+ return 1;
+ }
+
+ private int handleSetHost(CommandContext<FabricClientCommandSource> ctx) {
+ bridgeHost = StringArgumentType.getString(ctx, "host");
+ saveConfig();
+ sendCmdFeedback(ctx, "Host set to " + bridgeHost + ". Use /bridge connect to apply.");
+ return 1;
+ }
+
+ private int handleSetToken(CommandContext<FabricClientCommandSource> ctx) {
+ bridgeToken = StringArgumentType.getString(ctx, "token");
+ saveConfig();
+ sendCmdFeedback(ctx, "Token set. Use /bridge connect to apply.");
+ return 1;
+ }
+
+ private int handleSetNick(CommandContext<FabricClientCommandSource> ctx) {
+ String requested = StringArgumentType.getString(ctx, "nick");
+
+ int nickLength = requested.codePointCount(0, requested.length());
+ if (nickLength > MAX_NICK_LEN) {
+ sendCmdFeedback(ctx, "Nick too long (max " + MAX_NICK_LEN + " characters).");
+ return 1;
+ }
+
+ bridgeNick = requested;
+ saveConfig();
+
+ if (PlutoBridge.BRIDGE_CLIENT != null && PlutoBridge.BRIDGE_CLIENT.isConnected()) {
+ PlutoBridge.BRIDGE_CLIENT.updateNick(bridgeNick);
+ sendCmdFeedback(ctx, "Nick set to " + bridgeNick + " (updated live).");
+ } else {
+ sendCmdFeedback(ctx, "Nick set to " + bridgeNick + ". Use /bridge connect to apply.");
+ }
+ return 1;
+ }
+
+ private void sendCmdFeedback(CommandContext<FabricClientCommandSource> ctx, String grayMessage) {
+ MutableText prefix = Text.literal("[Bridge] ").formatted(Formatting.AQUA);
+ MutableText body = Text.literal(grayMessage).formatted(Formatting.GRAY);
+ ctx.getSource().sendFeedback(prefix.append(body));
+ }
+
+ private static class Config {
+ String host;
+ String token;
+ 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..6ea5e64
--- /dev/null
+++ b/src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java
@@ -0,0 +1,103 @@
+package dev.plutorocks.mixin;
+
+import dev.plutorocks.PlutoBridge;
+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 bridge$interceptBridgeMessages(String chatText, boolean addToHistory, CallbackInfo ci) {
+ if (chatText == null) {
+ return;
+ }
+
+ String trimmed = chatText.trim();
+ if (trimmed.isEmpty()) {
+ return;
+ }
+
+ if (trimmed.startsWith("/")) {
+ return;
+ }
+
+ boolean startsWithBang = trimmed.startsWith("!");
+ boolean bridgeDefault = PlutoBridge.bridgeChatDefault;
+
+ boolean wantsBridge;
+ if (!bridgeDefault) {
+ if (!startsWithBang) {
+ return;
+ }
+ wantsBridge = true;
+ } else {
+ wantsBridge = !startsWithBang;
+ }
+
+ MinecraftClient client = MinecraftClient.getInstance();
+ if (client == null || client.player == null) {
+ return;
+ }
+
+ if (PlutoBridge.BRIDGE_CLIENT == null || !PlutoBridge.BRIDGE_CLIENT.isConnected()) {
+ if (wantsBridge) {
+ if (client.inGameHud != null) {
+ MutableText prefix = Text.literal("[Bridge] ").formatted(Formatting.AQUA);
+ MutableText body = Text.literal("Not connected. Use /bridge connect first.")
+ .formatted(Formatting.GRAY);
+ client.inGameHud.getChatHud().addMessage(prefix.append(body));
+ }
+ ci.cancel();
+ }
+ return;
+ }
+
+ // at this point we are connected.
+ if (bridgeDefault && startsWithBang && !wantsBridge) {
+ // bridge mode ON and user prefixed with "!" => send to normal chat.
+ String normalMessage = trimmed.substring(1);
+ if (!normalMessage.isEmpty()) {
+ client.player.networkHandler.sendChatMessage(normalMessage);
+ }
+ } else {
+ // send to the bridge.
+ String bridgeMessage;
+ if (!bridgeDefault) {
+ // default mode: strip the leading "!" for bridge messages.
+ bridgeMessage = trimmed.substring(1);
+ } else {
+ // bridge mode: already without prefix for bridge messages.
+ bridgeMessage = trimmed;
+ }
+
+ // enforce max message length client-side to match server.
+ int len = bridgeMessage.codePointCount(0, bridgeMessage.length());
+ if (len > PlutoBridge.MAX_MESSAGE_LEN) {
+ if (client.inGameHud != null) {
+ MutableText prefix = Text.literal("[Bridge] ").formatted(Formatting.AQUA);
+ MutableText body = Text.literal(
+ "Message too long (max " + PlutoBridge.MAX_MESSAGE_LEN + " characters)."
+ ).formatted(Formatting.GRAY);
+ client.inGameHud.getChatHud().addMessage(prefix.append(body));
+ }
+ ci.cancel();
+ return;
+ }
+
+ if (!bridgeMessage.isEmpty()) {
+ PlutoBridge.BRIDGE_CLIENT.sendChatMessage(bridgeMessage);
+ }
+ }
+
+ // prevent this message from going to normal MC chat
+ ci.cancel();
+ }
+} \ No newline at end of file
diff --git a/src/main/resources/assets/minecraftirc/icon.png b/src/main/resources/assets/minecraftirc/icon.png
new file mode 100644
index 0000000..0de3188
--- /dev/null
+++ b/src/main/resources/assets/minecraftirc/icon.png
Binary files differ
diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..04ee800
--- /dev/null
+++ b/src/main/resources/fabric.mod.json
@@ -0,0 +1,31 @@
+{
+ "schemaVersion": 1,
+ "id": "minecraftirc",
+ "version": "${version}",
+ "name": "PlutoBridge Client",
+ "description": "Client for PlutoBridge",
+ "authors": [
+ "plutorocks"
+ ],
+ "contact": {
+ "homepage": "https://plutorocks.dev/",
+ "sources": "https://github.com/FabricMC/fabric-example-mod"
+ },
+ "license": "GPL-3.0-only",
+ "icon": "assets/minecraftirc/icon.png",
+ "environment": "*",
+ "entrypoints": {
+ "client": [
+ "dev.plutorocks.PlutoBridge"
+ ]
+ },
+ "mixins": [
+ "minecraftirc.mixins.json"
+ ],
+ "depends": {
+ "fabricloader": ">=0.18.4",
+ "minecraft": "~1.21.4",
+ "java": ">=21",
+ "fabric-api": "*"
+ }
+} \ No newline at end of file
diff --git a/src/main/resources/minecraftirc.mixins.json b/src/main/resources/minecraftirc.mixins.json
new file mode 100644
index 0000000..fc6e0cd
--- /dev/null
+++ b/src/main/resources/minecraftirc.mixins.json
@@ -0,0 +1,14 @@
+{
+ "required": true,
+ "package": "dev.plutorocks.mixin",
+ "compatibilityLevel": "JAVA_21",
+ "mixins": [
+ "ChatScreenMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ },
+ "overwrites": {
+ "requireAnnotations": true
+ }
+} \ No newline at end of file