Add Fabric client IRC chat mod and Drone pipeline
This commit is contained in:
108
src/main/java/ru/nevetime/clientirc/ClientIrcMod.java
Normal file
108
src/main/java/ru/nevetime/clientirc/ClientIrcMod.java
Normal 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";
|
||||
}
|
||||
}
|
||||
|
||||
78
src/main/java/ru/nevetime/clientirc/IrcConfig.java
Normal file
78
src/main/java/ru/nevetime/clientirc/IrcConfig.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
src/main/java/ru/nevetime/clientirc/IrcMessage.java
Normal file
32
src/main/java/ru/nevetime/clientirc/IrcMessage.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
206
src/main/java/ru/nevetime/clientirc/IrcService.java
Normal file
206
src/main/java/ru/nevetime/clientirc/IrcService.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
|
||||
25
src/main/resources/fabric.mod.json
Normal file
25
src/main/resources/fabric.mod.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user