diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8a8ca..a92f44f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.2] - 2024-11-10 + +### Added + +- **ChatITCommand.java**: + - **`/r` Command**: + - Introduced the `/r` command for quick replies to the last received private message. + - Allows players to send responses without specifying the recipient's name. + +- **Utils.java**: + - **Message Logging**: + - Implemented logging for private and global messages into separate files: `logs/private_messages.log` and `logs/global_messages.log`. + - Each message is logged with timestamp, sender's IP address, player names, and message content. + + - **Clickable Nicknames and Links in Chat**: + - Added clickable player nicknames in chat. Clicking on a player's nickname suggests sending a private message to them. + - Enabled clickable URLs in chat messages. Valid URLs are automatically converted into clickable links that open in the player's browser upon clicking. + - Implemented URL validation before converting them into clickable components. + ## [0.1.9] - 2024-10-31 ### Added diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 22da712..85af54e 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -4,6 +4,26 @@ Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/), и этот проект придерживается [Семантического Версионирования](https://semver.org/lang/ru/). +## [0.2] - 2024-11-10 + +### Добавлено + +- **ChatITCommand.java**: + - **Команда `/r`**: + - Добавлена команда `/r` для быстрого ответа на последнее полученное приватное сообщение. + - Позволяет игрокам быстро отправлять ответы без необходимости указывать имя получателя. + +- **Utils.java**: + - **Логирование сообщений**: + - Реализовано логирование приватных и глобальных сообщений в отдельные файлы `logs/private_messages.log` и `logs/global_messages.log`. + - Каждое сообщение логируется с указанием времени, IP-адреса отправителя, имен игроков и содержания сообщения. + + - **Кликабельность ников и ссылок в чате**: + - Добавлена возможность кликабельности ников игроков в чате. При клике на ник игрока предлагается отправить приватное сообщение. + - Внедрена кликабельность ссылок в сообщениях чата. Валидные URL автоматически преобразуются в кликабельные ссылки, которые открываются в браузере игрока при клике. + - Реализована проверка валидности ссылок перед их преобразованием в кликабельные компоненты. + + ## [0.1.9] - 2024-10-31 ### Добавлено diff --git a/gradle.properties b/gradle.properties index e8751db..bb44261 100644 --- a/gradle.properties +++ b/gradle.properties @@ -38,7 +38,7 @@ mod_name=ChatIT # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=All Rights Reserved # The mod version. See https://semver.org/ -mod_version=0.1.9-BETA +mod_version=0.2-BETA # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/src/main/java/org/itqop/chatit/commands/ChatITCommand.java b/src/main/java/org/itqop/chatit/commands/ChatITCommand.java index 5bfd3eb..ab8ec11 100644 --- a/src/main/java/org/itqop/chatit/commands/ChatITCommand.java +++ b/src/main/java/org/itqop/chatit/commands/ChatITCommand.java @@ -10,16 +10,27 @@ import net.minecraft.commands.arguments.MessageArgument; import net.minecraft.server.level.ServerPlayer; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; +import net.minecraft.world.entity.player.Player; import net.minecraftforge.event.RegisterCommandsEvent; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.event.entity.player.PlayerEvent.PlayerLoggedOutEvent; + import org.itqop.chatit.utils.PlayerConfigManager; import org.itqop.chatit.utils.Config; import org.itqop.chatit.utils.ProfanityMode; +import static org.itqop.chatit.utils.Utils.logPrivateMessage; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; + @Mod.EventBusSubscriber public class ChatITCommand { + private static final Map lastSenders = new ConcurrentHashMap<>(); + @SubscribeEvent public static void onRegisterCommands(RegisterCommandsEvent event) { CommandDispatcher dispatcher = event.getDispatcher(); @@ -36,7 +47,6 @@ public class ChatITCommand { return 1; }) ) - // Добавление подкоманды mode .then(Commands.literal("mode") .requires(source -> source.hasPermission(2)) // Требует уровень разрешения 2 (оператор) .then(Commands.argument("mode", StringArgumentType.word()) @@ -67,6 +77,31 @@ public class ChatITCommand { ) ); + // Регистрация команды /r + dispatcher.register(Commands.literal("r") + .then(Commands.argument("message", MessageArgument.message()) + .executes(context -> { + ServerPlayer sender = context.getSource().getPlayerOrException(); + UUID lastSenderUUID = lastSenders.get(sender.getUUID()); + if (lastSenderUUID == null) { + context.getSource().sendFailure(Component.literal("Нет игрока для ответа.") + .withStyle(ChatFormatting.RED)); + return 0; + } + + ServerPlayer target = context.getSource().getServer().getPlayerList().getPlayer(lastSenderUUID); + if (target == null) { + context.getSource().sendFailure(Component.literal("Последний отправитель недоступен.") + .withStyle(ChatFormatting.RED)); + return 0; + } + + Component message = MessageArgument.getMessage(context, "message"); + + return sendPrivateMessage(context.getSource(), target, message); + }) + ) + ); } public static void registerPrivateMessageCommand(CommandDispatcher dispatcher, String commandName) { @@ -90,15 +125,15 @@ public class ChatITCommand { * @return Статус выполнения команды. */ private static int sendPrivateMessage(CommandSourceStack source, ServerPlayer target, Component message) { - // Проверка, что источник команды является игроком if (!(source.getEntity() instanceof ServerPlayer sender)) { - source.sendFailure(Component.literal("Только игроки могут отправлять личные сообщения.")); + source.sendFailure(Component.literal("Только игроки могут отправлять личные сообщения.") + .withStyle(ChatFormatting.RED)); return 0; } - // Проверка, что отправитель не отправляет сообщение самому себе if (sender.getUUID().equals(target.getUUID())) { - sender.sendSystemMessage(Component.literal("Вы не можете отправлять сообщения самому себе.")); + sender.sendSystemMessage(Component.literal("Вы не можете отправлять сообщения самому себе.") + .withStyle(ChatFormatting.RED)); return 0; } @@ -124,6 +159,26 @@ public class ChatITCommand { target.sendSystemMessage(messageFrom); sender.sendSystemMessage(messageTo); + lastSenders.put(target.getUUID(), sender.getUUID()); + + logPrivateMessage(sender, target, message); + return 1; } + + /** + * Слушатель события отключения игрока для очистки хэшмапы. + * + * @param event Событие отключения игрока. + */ + @SubscribeEvent + public static void onPlayerLogout(PlayerLoggedOutEvent event) { + Player player = event.getEntity(); + UUID playerUUID = player.getUUID(); + + lastSenders.values().removeIf(senderUUID -> senderUUID.equals(playerUUID)); + + lastSenders.remove(playerUUID); + } + } diff --git a/src/main/java/org/itqop/chatit/handlers/ChatEventHandler.java b/src/main/java/org/itqop/chatit/handlers/ChatEventHandler.java index 40b5cd2..336c787 100644 --- a/src/main/java/org/itqop/chatit/handlers/ChatEventHandler.java +++ b/src/main/java/org/itqop/chatit/handlers/ChatEventHandler.java @@ -1,5 +1,7 @@ package org.itqop.chatit.handlers; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.HoverEvent; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.event.ServerChatEvent; import net.minecraftforge.fml.common.Mod; @@ -15,16 +17,27 @@ import org.slf4j.Logger; import com.mojang.logging.LogUtils; import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.itqop.chatit.utils.Utils.isValidUrl; +import static org.itqop.chatit.utils.Utils.logGlobalMessage; @Mod.EventBusSubscriber public class ChatEventHandler { private static final Logger LOGGER = LogUtils.getLogger(); + private static final Pattern URL_PATTERN = Pattern.compile( + "(https?://\\S+)", Pattern.CASE_INSENSITIVE + ); + @SubscribeEvent public static void onServerChat(ServerChatEvent event) { - ServerPlayer sender = event.getPlayer(); + MutableComponent errorComponent = createErrorMessage(Config.messageLocal); + MutableComponent adultComponent = createAdultMessage(); + ServerPlayer sender = event.getPlayer(); MinecraftServer server = sender.getServer(); String originalMessage = event.getMessage().getString(); @@ -37,6 +50,8 @@ public class ChatEventHandler { MutableComponent chatComponent = createChatMessage(sender, message, isGlobal); + logGlobalMessage(chatComponent); + CompletableFuture future = ProfanityChecker.checkMessageAsync(message); future.thenAccept(profanityScore -> { @@ -45,7 +60,6 @@ public class ChatEventHandler { double threshold = Config.profanityThreshold; if (profanityScore > threshold && !PlayerConfigManager.hasAdultContentEnabled(sender)) { - MutableComponent adultComponent = createAdultMessage(); sender.sendSystemMessage(adultComponent); } else { boolean localMessageSent = false; @@ -73,7 +87,6 @@ public class ChatEventHandler { if (!isGlobal) { if (!localMessageSent) { - MutableComponent errorComponent = createErrorMessage(Config.messageLocal); sender.sendSystemMessage(errorComponent); } else { sender.sendSystemMessage(chatComponent); @@ -86,8 +99,8 @@ public class ChatEventHandler { LOGGER.error(ex.toString()); if (server != null) { server.execute(() -> { - MutableComponent errorComponent = createErrorMessage(message); - sender.sendSystemMessage(errorComponent); + MutableComponent errorCustomComponent = createErrorMessage(message); + sender.sendSystemMessage(errorCustomComponent); }); } return null; @@ -120,6 +133,14 @@ public class ChatEventHandler { return prefixComponent.append(errorMessage); } + /** + * Метод для создания сообщения чата с кликабельными ссылками. + * + * @param player Игрок, отправляющий сообщение. + * @param message Текст сообщения. + * @param isGlobal Флаг, указывающий на глобальное или локальное сообщение. + * @return Кликабельное сообщение чата. + */ private static MutableComponent createChatMessage(ServerPlayer player, String message, boolean isGlobal) { MutableComponent openBracket = Component.literal("["); MutableComponent letterComponent; @@ -135,10 +156,47 @@ public class ChatEventHandler { MutableComponent prefixComponent = openBracket.append(letterComponent).append(closeBracket); - Component playerName = Component.literal(player.getName().getString()); - Component separatorComponent = Component.literal(": "); - Component messageComponent = Component.literal(message).withStyle(ChatFormatting.YELLOW); + MutableComponent playerName = Component.literal(player.getName().getString()) + .withStyle(style -> style + .withClickEvent(new ClickEvent(ClickEvent.Action.SUGGEST_COMMAND, "/msg " + player.getName().getString() + " ")) + .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("Нажмите, чтобы ответить"))) + .withColor(ChatFormatting.WHITE) + ); - return prefixComponent.append(playerName).append(separatorComponent).append(messageComponent); + Component separatorComponent = Component.literal(": "); + + MutableComponent messageComponent = Component.empty(); + Matcher matcher = URL_PATTERN.matcher(message); + int lastEnd = 0; + + while (matcher.find()) { + if (matcher.start() > lastEnd) { + String textBeforeUrl = message.substring(lastEnd, matcher.start()); + messageComponent.append(Component.literal(textBeforeUrl)); + } + + String url = matcher.group(1); + if (isValidUrl(url)) { + MutableComponent clickableLink = Component.literal(url) + .withStyle(style -> style + .withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)) + .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("Нажмите, чтобы открыть ссылку"))) + .withColor(ChatFormatting.BLUE) + .withUnderlined(true) + ); + messageComponent.append(clickableLink); + } else { + messageComponent.append(Component.literal(url)); + } + + lastEnd = matcher.end(); + } + + if (lastEnd < message.length()) { + String remainingText = message.substring(lastEnd); + messageComponent.append(Component.literal(remainingText)); + } + + return prefixComponent.append(playerName).append(separatorComponent).append(messageComponent.withStyle(ChatFormatting.YELLOW)); } } diff --git a/src/main/java/org/itqop/chatit/utils/Utils.java b/src/main/java/org/itqop/chatit/utils/Utils.java new file mode 100644 index 0000000..7ae7103 --- /dev/null +++ b/src/main/java/org/itqop/chatit/utils/Utils.java @@ -0,0 +1,112 @@ +package org.itqop.chatit.utils; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.network.ServerGamePacketListenerImpl; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.net.URL; + + +public class Utils { + + private static final DateTimeFormatter LOG_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private static final String PRIVATE_MESSAGES_LOG = "logs/private_messages.log"; + private static final String GLOBAL_MESSAGES_LOG = "logs/global_messages.log"; + + /** + * Метод для получения IP-адреса игрока. + * + * @param player Игрок, чей IP требуется получить. + * @return IP-адрес игрока в строковом формате. + */ + private static String getPlayerIp(ServerPlayer player) { + ServerGamePacketListenerImpl connection = player.connection; + + InetSocketAddress address = (InetSocketAddress) connection.connection.getRemoteAddress(); + + InetAddress inetAddress = address.getAddress(); + if (inetAddress == null) { + return "Unknown IP"; + } + + if (inetAddress instanceof Inet4Address) { + return inetAddress.getHostAddress(); + } else if (inetAddress instanceof Inet6Address) { + String ip = inetAddress.getHostAddress(); + int percentIndex = ip.indexOf('%'); + if (percentIndex != -1) { + ip = ip.substring(0, percentIndex); + } + return ip; + } else { + return inetAddress.getHostAddress(); + } + } + + /** + * Метод для логирования приватных сообщений в файл. + * + * @param sender Игрок, отправляющий сообщение. + * @param target Целевой игрок. + * @param message Текст сообщения. + */ + public static void logPrivateMessage(ServerPlayer sender, ServerPlayer target, Component message) { + String time = LocalDateTime.now().format(LOG_TIME_FORMAT); + String senderIp = getPlayerIp(sender); + String senderName = sender.getName().getString(); + String targetName = target.getName().getString(); + String messageText = message.getString(); + String logEntry = String.format("[%s] [%s] - %s -> %s: %s%n", time, senderIp, senderName, targetName, messageText); + + try (BufferedWriter writer = new BufferedWriter(new FileWriter(PRIVATE_MESSAGES_LOG, true))) { + writer.write(logEntry); + } catch (IOException e) { + //LOGGER.error("Не удалось записать приватное сообщение в лог.", e); + } + } + + /** + * Метод для логирования глобальных сообщений в файл. + * + * @param message Глобальное сообщение в формате MutableComponent. + */ + public static void logGlobalMessage(MutableComponent message) { + String time = LocalDateTime.now().format(LOG_TIME_FORMAT); + String messageText = message.getString(); + String logEntry = String.format("[%s] %s%n", time, messageText); + + try (BufferedWriter writer = new BufferedWriter(new FileWriter(GLOBAL_MESSAGES_LOG, true))) { + writer.write(logEntry); + } catch (IOException e) { + //LOGGER.error("Не удалось записать глобальное сообщение в лог.", e); + } + } + + /** + * Метод для проверки валидности URL. + * В будущем сюда можно добавить дополнительные проверки, например, DGA. + * + * @param url Строка с URL для проверки. + * @return true, если URL валиден, иначе false. + */ + public static boolean isValidUrl(String url) { + try { + new URL(url).toURI(); // Проверяем синтаксис URL + return true; + } catch (Exception e) { + return false; + } + } + +}