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

18
.drone.yml Normal file
View File

@@ -0,0 +1,18 @@
kind: pipeline
type: docker
name: build
trigger:
event:
- push
- pull_request
steps:
- name: gradle-build
image: gradle:8.10.2-jdk21
environment:
GRADLE_USER_HOME: /drone/src/.gradle
commands:
- gradle --no-daemon clean build
- ls -la build/libs

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.gradle/
build/
out/
run/
*.iml
.idea/
*.class

143
README.md Normal file
View File

@@ -0,0 +1,143 @@
# Client IRC Chat
Клиентский Fabric-мод, который перехватывает сообщения вида `.irc <текст>` и отправляет их на отдельный TCP IRC-подобный сервер.
Формат сообщения в чате клиента:
`НАЗВАНИЕ-КЛИЕНТА >> Ник-игрока: сообщение`
Пример:
`MY-CLIENT >> Arkon: Привет`
## Что входит
- клиентский Fabric-мод;
- команда `.irc <сообщение>` через обычный чат Minecraft;
- служебные команды `.irc status`, `.irc reconnect`, `.irc reload`;
- пример простого IRC-сервера на Node.js без внешних зависимостей;
- `/.drone.yml` для сборки через Drone CI.
## Важное допущение по версии
Я заложил сборку под связку:
- Minecraft `1.21.6`
- Fabric Loader `0.16.14`
- Fabric API `0.126.2+1.21.6`
Если под "Fabric 26.2" ты имел в виду другую точную версию, поправь [gradle.properties](/D:/Desktop/gdfg/gradle.properties) и [src/main/resources/fabric.mod.json](/D:/Desktop/gdfg/src/main/resources/fabric.mod.json).
## Команды в игре
- `.irc Привет` - отправить сообщение на IRC-сервер
- `.irc status` - показать состояние подключения
- `.irc reconnect` - переподключиться к IRC-серверу
- `.irc reload` - перечитать конфиг из файла
## Конфиг клиента
После первого запуска мод создаст:
`config/clientirc.json`
Пример:
```json
{
"host": "127.0.0.1",
"port": 24469,
"clientName": "MY-CLIENT",
"token": "",
"autoConnect": true,
"connectTimeoutMs": 5000
}
```
Поля:
- `host` - адрес IRC-сервера
- `port` - порт IRC-сервера
- `clientName` - то самое `НАЗВАНИЕ-КЛИЕНТА`
- `token` - общий токен, если хочешь ограничить подключение
- `autoConnect` - подключаться автоматически при старте клиента
## Как поднять сервер для IRC-чата
В репозитории уже есть пример сервера: [server/irc-server.js](/D:/Desktop/gdfg/server/irc-server.js)
### 1. Установи Node.js
Нужен Node.js 20+.
### 2. Настрой сервер
Файл конфига: [server/config.json](/D:/Desktop/gdfg/server/config.json)
Пример:
```json
{
"host": "0.0.0.0",
"port": 24469,
"token": "secret-token"
}
```
Если укажешь `token`, такой же токен нужно прописать в `config/clientirc.json` на клиентах.
### 3. Запусти сервер
```bash
cd server
node irc-server.js
```
### 4. Открой порт
Если сервер не локальный, открой TCP-порт `24469` в фаерволе и пробрось его на роутере/VPS.
### 5. Подключи клиентов
На каждом клиенте укажи:
- `host` - IP или домен сервера
- `port` - тот же порт
- `clientName` - имя клиента
- `token` - если используется
После этого в игре можно писать:
```text
.irc Привет
```
И все подключенные клиенты увидят:
```text
[IRC] MY-CLIENT >> Arkon: Привет
```
## Сборка мода
Для локальной сборки нужен Gradle 8.10+ и Java 21.
```bash
gradle build
```
Артефакт будет в:
`build/libs/`
## Drone CI
Файл пайплайна: [/.drone.yml](/D:/Desktop/gdfg/.drone.yml)
Он делает:
- запуск Docker pipeline;
- `gradle clean build`;
- вывод содержимого `build/libs`.
Для работы Drone runner должен иметь доступ в интернет, чтобы скачать зависимости Gradle/Fabric.

55
build.gradle Normal file
View File

@@ -0,0 +1,55 @@
plugins {
id 'fabric-loom' version '1.10-SNAPSHOT'
id 'maven-publish'
}
version = project.mod_version
group = project.maven_group
base {
archivesName = project.archives_base_name
}
repositories {
mavenCentral()
maven {
url = 'https://maven.fabricmc.net/'
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
}
processResources {
inputs.property 'version', project.version
filesMatching('fabric.mod.json') {
expand 'version': project.version
}
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 21
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
withSourcesJar()
}
publishing {
publications {
mavenJava(MavenPublication) {
from components.java
}
}
repositories {
}
}

14
gradle.properties Normal file
View File

@@ -0,0 +1,14 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true
# Assumption: "Fabric 26.2" refers to Fabric API 0.126.2 for Minecraft 1.21.6.
# If your target differs, update the four values below together.
minecraft_version=1.21.6
yarn_mappings=1.21.6+build.1
loader_version=0.16.14
fabric_version=0.126.2+1.21.6
mod_version=1.0.0
maven_group=ru.nevetime
archives_base_name=client-irc-chat

6
server/config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"host": "0.0.0.0",
"port": 24469,
"token": ""
}

132
server/irc-server.js Normal file
View File

@@ -0,0 +1,132 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const net = require('node:net');
const configPath = path.join(__dirname, 'config.json');
const defaultConfig = {
host: '0.0.0.0',
port: 24469,
token: ''
};
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
}
const config = {
...defaultConfig,
...JSON.parse(fs.readFileSync(configPath, 'utf8'))
};
const clients = new Set();
function sendJson(socket, payload) {
socket.write(JSON.stringify(payload) + '\n');
}
function broadcast(payload) {
const line = JSON.stringify(payload) + '\n';
for (const socket of clients) {
if (!socket.destroyed) {
socket.write(line);
}
}
}
function systemMessage(message) {
return {
type: 'system',
message,
timestamp: Date.now()
};
}
const server = net.createServer((socket) => {
socket.setEncoding('utf8');
clients.add(socket);
let buffer = '';
sendJson(socket, systemMessage('Connected to IRC server.'));
console.log(`[connect] ${socket.remoteAddress}:${socket.remotePort}`);
socket.on('data', (chunk) => {
buffer += chunk;
let separatorIndex = buffer.indexOf('\n');
while (separatorIndex !== -1) {
const rawLine = buffer.slice(0, separatorIndex).trim();
buffer = buffer.slice(separatorIndex + 1);
separatorIndex = buffer.indexOf('\n');
if (!rawLine) {
continue;
}
let payload;
try {
payload = JSON.parse(rawLine);
} catch (error) {
sendJson(socket, systemMessage(`Bad JSON: ${error.message}`));
continue;
}
if (config.token && payload.token !== config.token) {
sendJson(socket, systemMessage('Invalid token.'));
continue;
}
if (payload.type === 'hello') {
const name = payload.player || 'unknown';
sendJson(socket, systemMessage(`Hello, ${name}.`));
continue;
}
if (payload.type === 'chat') {
const clientName = String(payload.client || 'UNKNOWN-CLIENT').trim();
const playerName = String(payload.player || 'unknown').trim();
const message = String(payload.message || '').trim();
if (!message) {
sendJson(socket, systemMessage('Empty chat message ignored.'));
continue;
}
const outgoing = {
type: 'chat',
client: clientName,
player: playerName,
message,
timestamp: Date.now()
};
console.log(`[chat] ${clientName} >> ${playerName}: ${message}`);
broadcast(outgoing);
continue;
}
sendJson(socket, systemMessage(`Unknown packet type: ${payload.type}`));
}
});
socket.on('close', () => {
clients.delete(socket);
console.log(`[disconnect] ${socket.remoteAddress}:${socket.remotePort}`);
});
socket.on('error', (error) => {
clients.delete(socket);
console.error(`[socket-error] ${socket.remoteAddress}:${socket.remotePort} ${error.message}`);
});
});
server.on('error', (error) => {
console.error(`[server-error] ${error.message}`);
process.exitCode = 1;
});
server.listen(config.port, config.host, () => {
console.log(`IRC server listening on ${config.host}:${config.port}`);
});

12
settings.gradle Normal file
View File

@@ -0,0 +1,12 @@
pluginManagement {
repositories {
maven {
url = 'https://maven.fabricmc.net/'
}
gradlePluginPortal()
mavenCentral()
}
}
rootProject.name = 'client-irc-chat'

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": "*"
}
}