Add Fabric client IRC chat mod and Drone pipeline

This commit is contained in:
2026-06-21 14:31:38 +06:00
commit 910c58c30b
13 changed files with 837 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
package ru.nevetime.clientirc;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.message.v1.ClientSendMessageEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class ClientIrcMod implements ClientModInitializer {
public static final String MOD_ID = "clientirc";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private static final String COMMAND_PREFIX = ".irc";
private static IrcConfig config;
private static IrcService service;
@Override
public void onInitializeClient() {
config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json"));
service = new IrcService(config, LOGGER);
ClientLifecycleEvents.CLIENT_STARTED.register(client -> {
if (config.autoConnect) {
service.connect(resolvePlayerName(client));
}
});
ClientTickEvents.END_CLIENT_TICK.register(client -> {
IrcService.DisplayLine line;
while ((line = service.pollLine()) != null) {
Formatting color = line.system() ? Formatting.GRAY : Formatting.AQUA;
pushClientChat(client, Text.literal(line.text()).formatted(color));
}
});
ClientSendMessageEvents.ALLOW_CHAT.register(message -> !interceptOutgoingChat(message));
}
private static boolean interceptOutgoingChat(String rawMessage) {
String trimmed = rawMessage.trim();
if (!trimmed.startsWith(COMMAND_PREFIX)) {
return false;
}
MinecraftClient client = MinecraftClient.getInstance();
String argument = trimmed.length() == COMMAND_PREFIX.length() ? "" : trimmed.substring(COMMAND_PREFIX.length()).trim();
if (argument.isBlank()) {
pushClientChat(client, Text.literal("[IRC] Usage: .irc <message> | .irc status | .irc reconnect | .irc reload").formatted(Formatting.YELLOW));
return true;
}
if ("status".equalsIgnoreCase(argument)) {
String status = service.isConnected() ? "connected" : "disconnected";
pushClientChat(client, Text.literal("[IRC] " + status + " | " + config.host + ":" + config.port + " | clientName=" + config.clientName).formatted(Formatting.YELLOW));
return true;
}
if ("reconnect".equalsIgnoreCase(argument)) {
boolean connected = service.reconnect(resolvePlayerName(client));
Formatting color = connected ? Formatting.GREEN : Formatting.RED;
pushClientChat(client, Text.literal("[IRC] reconnect " + (connected ? "ok" : "failed")).formatted(color));
return true;
}
if ("reload".equalsIgnoreCase(argument)) {
config = IrcConfig.load(FabricLoader.getInstance().getConfigDir().resolve("clientirc.json"));
service.updateConfig(config);
pushClientChat(client, Text.literal("[IRC] config reloaded").formatted(Formatting.YELLOW));
return true;
}
if (!service.ensureConnected(resolvePlayerName(client))) {
pushClientChat(client, Text.literal("[IRC] server is offline or config is wrong").formatted(Formatting.RED));
return true;
}
boolean sent = service.sendChat(resolvePlayerName(client), argument);
if (!sent) {
pushClientChat(client, Text.literal("[IRC] send failed").formatted(Formatting.RED));
}
return true;
}
private static void pushClientChat(MinecraftClient client, Text text) {
if (client == null) {
return;
}
client.execute(() -> client.inGameHud.getChatHud().addMessage(text));
}
private static String resolvePlayerName(MinecraftClient client) {
if (client != null && client.player != null) {
return client.player.getGameProfile().getName();
}
if (client != null && client.getSession() != null) {
return client.getSession().getUsername();
}
return "unknown";
}
}

View File

@@ -0,0 +1,78 @@
package ru.nevetime.clientirc;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public final class IrcConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
public String host = "127.0.0.1";
public int port = 24469;
public String clientName = "MY-CLIENT";
public String token = "";
public boolean autoConnect = true;
public int connectTimeoutMs = 5000;
public static IrcConfig load(Path path) {
try {
Files.createDirectories(path.getParent());
if (Files.notExists(path)) {
IrcConfig config = new IrcConfig();
config.save(path);
return config;
}
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
IrcConfig config = GSON.fromJson(reader, IrcConfig.class);
if (config == null) {
config = new IrcConfig();
}
config.normalize();
config.save(path);
return config;
}
} catch (IOException | JsonParseException exception) {
ClientIrcMod.LOGGER.error("Failed to load IRC config from {}", path, exception);
IrcConfig fallback = new IrcConfig();
try {
fallback.save(path);
} catch (IOException ioException) {
ClientIrcMod.LOGGER.error("Failed to save fallback IRC config to {}", path, ioException);
}
return fallback;
}
}
public void save(Path path) throws IOException {
Files.createDirectories(path.getParent());
try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
GSON.toJson(this, writer);
}
}
public void normalize() {
if (host == null || host.isBlank()) {
host = "127.0.0.1";
}
if (port <= 0 || port > 65535) {
port = 24469;
}
if (clientName == null || clientName.isBlank()) {
clientName = "MY-CLIENT";
}
if (token == null) {
token = "";
}
if (connectTimeoutMs < 1000) {
connectTimeoutMs = 5000;
}
}
}

View File

@@ -0,0 +1,32 @@
package ru.nevetime.clientirc;
public final class IrcMessage {
public String type;
public String client;
public String player;
public String message;
public String token;
public long timestamp;
public static IrcMessage hello(String clientName, String playerName, String token) {
IrcMessage message = new IrcMessage();
message.type = "hello";
message.client = clientName;
message.player = playerName;
message.token = token;
message.timestamp = System.currentTimeMillis();
return message;
}
public static IrcMessage chat(String clientName, String playerName, String text, String token) {
IrcMessage message = new IrcMessage();
message.type = "chat";
message.client = clientName;
message.player = playerName;
message.message = text;
message.token = token;
message.timestamp = System.currentTimeMillis();
return message;
}
}

View File

@@ -0,0 +1,206 @@
package ru.nevetime.clientirc;
import com.google.gson.Gson;
import com.google.gson.JsonParseException;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.ConcurrentLinkedQueue;
import org.slf4j.Logger;
public final class IrcService {
private static final Gson GSON = new Gson();
private final Logger logger;
private final ConcurrentLinkedQueue<DisplayLine> queue = new ConcurrentLinkedQueue<>();
private volatile IrcConfig config;
private volatile Socket socket;
private volatile BufferedWriter writer;
private volatile BufferedReader reader;
private volatile boolean running;
private volatile Thread readerThread;
public IrcService(IrcConfig config, Logger logger) {
this.config = Objects.requireNonNull(config, "config");
this.logger = Objects.requireNonNull(logger, "logger");
}
public synchronized void updateConfig(IrcConfig config) {
this.config = Objects.requireNonNull(config, "config");
}
public synchronized boolean ensureConnected(String playerName) {
return isConnected() || connect(playerName);
}
public synchronized boolean connect(String playerName) {
if (isConnected()) {
return true;
}
Socket newSocket = new Socket();
BufferedWriter newWriter = null;
BufferedReader newReader = null;
try {
newSocket.connect(new InetSocketAddress(config.host, config.port), config.connectTimeoutMs);
newSocket.setTcpNoDelay(true);
newWriter = new BufferedWriter(new OutputStreamWriter(newSocket.getOutputStream(), StandardCharsets.UTF_8));
newReader = new BufferedReader(new InputStreamReader(newSocket.getInputStream(), StandardCharsets.UTF_8));
socket = newSocket;
writer = newWriter;
reader = newReader;
running = true;
startReaderThread(newReader);
sendInternal(IrcMessage.hello(config.clientName, playerName, config.token));
enqueueSystem("Connected to " + config.host + ":" + config.port);
return true;
} catch (IOException exception) {
closeQuietly(newReader);
closeQuietly(newWriter);
closeQuietly(newSocket);
enqueueSystem("Connection failed: " + exception.getMessage());
logger.warn("IRC connection failed", exception);
return false;
}
}
public synchronized boolean reconnect(String playerName) {
disconnect();
return connect(playerName);
}
public synchronized void disconnect() {
running = false;
closeSocketState();
}
public synchronized boolean sendChat(String playerName, String text) {
if (!isConnected()) {
return false;
}
try {
sendInternal(IrcMessage.chat(config.clientName, playerName, text, config.token));
return true;
} catch (IOException exception) {
enqueueSystem("Send failed: " + exception.getMessage());
logger.warn("Failed to send IRC message", exception);
running = false;
closeSocketState();
return false;
}
}
public synchronized boolean isConnected() {
return socket != null && socket.isConnected() && !socket.isClosed() && writer != null;
}
public DisplayLine pollLine() {
return queue.poll();
}
private void startReaderThread(BufferedReader activeReader) {
Thread thread = new Thread(() -> readLoop(activeReader), "clientirc-reader");
thread.setDaemon(true);
readerThread = thread;
thread.start();
}
private void readLoop(BufferedReader activeReader) {
try {
String line;
while (running && (line = activeReader.readLine()) != null) {
handleIncomingLine(line);
}
if (running) {
enqueueSystem("Server closed the connection.");
}
} catch (IOException exception) {
if (running) {
enqueueSystem("Disconnected: " + exception.getMessage());
logger.warn("IRC reader stopped", exception);
}
} finally {
running = false;
synchronized (this) {
closeSocketState();
}
}
}
private void handleIncomingLine(String rawLine) {
try {
IrcMessage message = GSON.fromJson(rawLine, IrcMessage.class);
if (message == null || message.type == null) {
return;
}
if ("chat".equals(message.type)) {
String clientName = safeValue(message.client, "UNKNOWN-CLIENT");
String playerName = safeValue(message.player, "unknown");
String text = safeValue(message.message, "");
queue.add(new DisplayLine("[IRC] " + clientName + " >> " + playerName + ": " + text, false));
return;
}
if ("system".equals(message.type) && message.message != null && !message.message.isBlank()) {
enqueueSystem(message.message);
}
} catch (JsonParseException exception) {
enqueueSystem("Malformed packet: " + rawLine);
logger.warn("Failed to parse IRC packet: {}", rawLine, exception);
}
}
private void enqueueSystem(String text) {
queue.add(new DisplayLine("[IRC] " + text, true));
}
private void sendInternal(IrcMessage message) throws IOException {
writer.write(GSON.toJson(message));
writer.newLine();
writer.flush();
}
private synchronized void closeSocketState() {
closeQuietly(reader);
closeQuietly(writer);
closeQuietly(socket);
reader = null;
writer = null;
socket = null;
readerThread = null;
}
private static void closeQuietly(Closeable closeable) {
if (closeable == null) {
return;
}
try {
closeable.close();
} catch (IOException ignored) {
}
}
private static String safeValue(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
public record DisplayLine(String text, boolean system) {
}
}

View File

@@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"id": "clientirc",
"version": "${version}",
"name": "Client IRC Chat",
"description": "Client-side Fabric mod that redirects .irc messages to a standalone IRC-like TCP chat server.",
"authors": [
"Codex"
],
"contact": {},
"license": "MIT",
"environment": "client",
"entrypoints": {
"client": [
"ru.nevetime.clientirc.ClientIrcMod"
]
},
"depends": {
"fabricloader": ">=0.16.14",
"minecraft": "~1.21.6",
"java": ">=21",
"fabric-api": "*"
}
}