Add Fabric client IRC chat mod and Drone pipeline
This commit is contained in:
18
.drone.yml
Normal file
18
.drone.yml
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
run/
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
*.class
|
||||||
|
|
||||||
143
README.md
Normal file
143
README.md
Normal 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
55
build.gradle
Normal 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
14
gradle.properties
Normal 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
6
server/config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 24469,
|
||||||
|
"token": ""
|
||||||
|
}
|
||||||
|
|
||||||
132
server/irc-server.js
Normal file
132
server/irc-server.js
Normal 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
12
settings.gradle
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url = 'https://maven.fabricmc.net/'
|
||||||
|
}
|
||||||
|
gradlePluginPortal()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = 'client-irc-chat'
|
||||||
|
|
||||||
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