fix: critical fixes from code review — async translation, atomic persistence, unified chat pipeline, config key alignment

This commit is contained in:
loki5512344 2026-08-09 21:04:07 +02:00
parent 103c982f87
commit b1b0c9082b
Signed by: boba
GPG key ID: 253067914055423B
31 changed files with 530 additions and 384 deletions

86
TODO.md
View file

@ -1,6 +1,38 @@
# LoChat Development TODO # LoChat Development TODO
> Last updated: 2026-07-05 > Last updated: 2026-08-04
---
## ✅ CRITICAL FIXES (blocking release — from code review 2026-08-04, all DONE)
### 1. Translation: async + bounded cache, no String.intern(), out of render() ✅
- `renderer/EnhancedChatRenderer.java` — removed blocking `applyTranslation()` from `render()` (zero network I/O in render path). Translation is now delivered via async action bar (`sendTranslationAsync` → `translateAsync().thenAccept`).
- `translate/TranslationService.java` — no more `String.intern()`; bounded LRU cache (2000 entries, synchronized LinkedHashMap); `translateAsync` runs on a dedicated 2-thread daemon executor; failures return original text, never throw.
### 2. Atomic file writes (temp + rename, per-file lock) for all persistence ✅
- `utils/persistence/FilePersistence.java` — `saveYaml`/`saveJson` now write `<name>.tmp` + `Files.move(ATOMIC_MOVE, REPLACE_EXISTING)` under a per-file `ReentrantLock`.
- `core/service/PlayerServiceImpl.java` — `statsLock` serializes async `persistPlayerMessages` and main-thread `saveAll()`.
- `core/service/PunishmentServiceImpl.java` — removed `saveAsync()` from `isBanned()` read path.
- `core/service/mute/MuteDataStorage.java` — uses `FilePersistence.saveJson` (atomic), dropped raw FileWriter.
- `core/service/mute/MuteHistoryManager.java` — atomic temp+rename write under per-file lock, no `e.printStackTrace()`.
### 3. Route /g and /l through the same filter pipeline + escape MiniMessage ✅
- `core/service/ChatServiceImpl.java` — `String` messages now run length check → `MessageService.processMessage` (mute/cooldown) → `AdvancedMessageFilter` → `MiniMessage.escapeTags()` → component (colors only with `lochat.chat.colors`). `Component` messages bypass (trusted internal). Local radius from `AppearanceConfig`.
- `commands/chat/GlobalChatCommand.java`, `LocalChatCommand.java` — pass plain `String`, no more `MM.deserialize(raw input)`.
- `LoChat.java`/`PluginInitializer.java` — filter instance stored on plugin; `ServiceRegistry` builds `MessageService` before `ChatService`.
### 4. Remove duplicate Discord send; single global/local detection ✅
- `listener/DiscordEventListener.java` — deleted the `onChat(AsyncChatEvent)` handler; `ChatEventListener` is now the single send path with correct `isGlobal`.
### 5. Fix config key mismatches (underscore vs dash / nesting) ✅
- `config/AppearanceConfig.java` — `chat.local-radius`, `chat.clear-lines`, `chat.min-message-length`, `chat.max-message-length`, `chat.colors.*` (dash), `prefixes.*.separator` (string), `discord.avatar-urls.*`, `discord.event-titles.*` (+ `_`→`-` normalization).
- `integrations/discord/DiscordConfig.java` — `webhook.avatar-url`, `chat.global-only`, `chat.use-embed`, `chat.embed-color`, `filter.*` (dash), `performance.retry-*`.
- `config/MuteConfig.java` — `settings.*`, `notifications.*`.
- `config/SoundsConfig.java` — `messages.mention.*`, `messages.private-message.*`, `commands.success.*`.
- `config/FiltersConfig.java` + filters — `config/filters.yml` is now the single source of truth (`caps.max-caps-percent`, `swear.replace`, `hidden-urls.*`, `spam.max-repeating-chars`, `flood.max-same-messages`).
- `gradient/config/GradientConfig.java` — nested keys `gradient.limits.*`, `gradient.pricing.*`, `gradient.cooldowns.*`, `gradient.formatting.*`, `gradient.storage.type` → **SQLite now detected**.
- `managers/AutoMessageManager.java` + `config.yml` — reads `automessages.messages` (new `automessages:` section added to config.yml), honors `automessages.random`.
--- ---
@ -32,7 +64,34 @@
--- ---
## 🟡 REMAINING (COULD DO) ## 🟡 NEXT: NEW FEATURES
### 1. Channels (like VentureChat / Carbon)
- `/join`, `/leave`, `/channel` — full channel system: global, local, staff, trade, party.
- Per-channel colors, aliases, permissions, cooldowns, spy mode.
- Party chat: private groups `/party`.
- Network channels (Bungee/Velocity) sync.
### 2. Anti-swear hardening + filter statistics
- Bypass obfuscation: `q→р` leet/Unicode substitution, repeated chars between letters, spaces/dots inside words.
- Track filter stats (how many messages blocked per filter) → expose via command/placeholder.
### 3. Stable plugin API + API events
- Publish `api/` modules to a repo so third-party plugins can depend on it.
- Add `api-version` hint / `provides` in `plugin.yml` for dependency resolution.
- Dedicated API events: `LoChatMessageEvent`, `LoChatFilterEvent`, `LoChatPunishEvent` etc.
### 4. DiscordSRV-style 2-way bridge (Discord → chat)
- Read messages from a Discord channel (bot or webhook listener) and relay into Minecraft chat.
- Channel ↔ Discord channel mapping.
### 5. Full spy + auto-mute for spam
- PM spy + command spy with custom formats (like VentureChat).
- Auto-mute repeat offenders (spam/flood) with configurable threshold and duration.
---
## 🔵 LOWER PRIORITY (carry-over)
### 1. Shared Persistence Utility ### 1. Shared Persistence Utility
5 services repeat load/save/ensureDir pattern: 5 services repeat load/save/ensureDir pattern:
@ -40,8 +99,8 @@
**Fix:** Extract `FilePersistenceUtil` or base class **Fix:** Extract `FilePersistenceUtil` or base class
### 2. Mute Duration Permissions Not Declared in plugin.yml ### 2. Persist nicknames on change
`lochat.mute.dur.5m`, `lochat.mute.dur.3h`, `lochat.mute.reason.*` — used in code/configs but missing from plugin.yml declarations. `NickServiceImpl.java:77` mutates only in-memory map; `save()` only on shutdown → nicknames lost on crash (async save after mutation).
### 3. DIP: Service Locator Anti-Pattern ### 3. DIP: Service Locator Anti-Pattern
`ServiceRegistry.get(Xxx.class)` used everywhere instead of constructor DI. Big refactor. `ServiceRegistry.get(Xxx.class)` used everywhere instead of constructor DI. Big refactor.
@ -49,20 +108,17 @@
### 4. DRY: PM Send Logic Duplicated ### 4. DRY: PM Send Logic Duplicated
`MsgCommand` + `ReplyCommand` still have near-identical send logic. `MsgCommand` + `ReplyCommand` still have near-identical send logic.
### 5. PM Send Logic Duplicated Between MsgCommand and ReplyCommand ### 5. Fix cooldown map collision
`PlayerServiceImpl.java:80-82` — all non-global types (local chat + RP `/me /do /try /roll`) share one map → cross-blocking cooldowns. Split per-type maps.
--- ### 6. Thread-safe flood/spam deques
`FloodFilter.java:32`, `SpamFilter.java:30` use plain `ArrayDeque` written from async threads, read from main → use synchronized/thread-safe structures.
## 🔵 LOW PRIORITY ### 7. Tests
Only 11 test files for 155 main files. Very low coverage (JaCoCo wired, CI uploads report).
### 6. Tests ### 8. No PM cooldown
Only 4 test files for 150+ main files. Very low coverage. `chat.pm.cooldown: 2` in config.yml is never read → `/msg` spam unthrottled.
### 7. Aho-Corasick for Swear Filter
Performance optimization for word filtering.
### 8. Discord Rate Limiter
Prevent rate limiting when sending many webhook messages.
--- ---

View file

@ -42,6 +42,7 @@ public final class LoChat extends JavaPlugin {
// Вспомогательные классы // Вспомогательные классы
private PluginInitializer initializer; private PluginInitializer initializer;
private PluginShutdown shutdown; private PluginShutdown shutdown;
private com.loki.lochat.core.filter.AdvancedMessageFilter chatFilter;
@Override @Override
public void onEnable() { public void onEnable() {
@ -153,4 +154,12 @@ public final class LoChat extends JavaPlugin {
public TranslationService getTranslationService() { public TranslationService getTranslationService() {
return translationService; return translationService;
} }
public com.loki.lochat.core.filter.AdvancedMessageFilter getChatFilter() {
return chatFilter;
}
public void setChatFilter(com.loki.lochat.core.filter.AdvancedMessageFilter chatFilter) {
this.chatFilter = chatFilter;
}
} }

View file

@ -4,8 +4,6 @@ import com.loki.lochat.LoChat;
import com.loki.lochat.api.service.ChatService; import com.loki.lochat.api.service.ChatService;
import com.loki.lochat.utils.format.ChatFormatter; import com.loki.lochat.utils.format.ChatFormatter;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
@ -14,7 +12,6 @@ import org.jetbrains.annotations.NotNull;
public class GlobalChatCommand implements CommandExecutor { public class GlobalChatCommand implements CommandExecutor {
private static final MiniMessage MM = MiniMessage.miniMessage();
private final LoChat plugin; private final LoChat plugin;
private final ChatService chatService; private final ChatService chatService;
@ -46,7 +43,7 @@ public class GlobalChatCommand implements CommandExecutor {
return true; return true;
} }
chatService.sendGlobalMessage(player, MM.deserialize(String.join(" ", args))); chatService.sendGlobalMessage(player, String.join(" ", args));
return true; return true;
} }
} }

View file

@ -4,8 +4,6 @@ import com.loki.lochat.LoChat;
import com.loki.lochat.api.service.ChatService; import com.loki.lochat.api.service.ChatService;
import com.loki.lochat.utils.format.ChatFormatter; import com.loki.lochat.utils.format.ChatFormatter;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
@ -14,7 +12,6 @@ import org.jetbrains.annotations.NotNull;
public class LocalChatCommand implements CommandExecutor { public class LocalChatCommand implements CommandExecutor {
private static final MiniMessage MM = MiniMessage.miniMessage();
private final LoChat plugin; private final LoChat plugin;
private final ChatService chatService; private final ChatService chatService;
@ -35,7 +32,7 @@ public class LocalChatCommand implements CommandExecutor {
return true; return true;
} }
chatService.sendLocalMessage(player, MM.deserialize(String.join(" ", args))); chatService.sendLocalMessage(player, String.join(" ", args));
return true; return true;
} }
} }

View file

@ -58,7 +58,7 @@ public class AppearanceConfig extends BaseConfig {
return getConfig().getStringList("prefixes.global.colors"); return getConfig().getStringList("prefixes.global.colors");
} }
public String getGlobalSeparatorText() { public String getGlobalSeparatorText() {
return getPrefixValue("global", "separator.text", " ▶ "); return getPrefixValue("global", "separator", " ▶ ");
} }
public String getGlobalSeparatorColor() { public String getGlobalSeparatorColor() {
return getPrefixValue("global", "separator.color", "#FFD700"); return getPrefixValue("global", "separator.color", "#FFD700");
@ -77,7 +77,7 @@ public class AppearanceConfig extends BaseConfig {
return getConfig().getStringList("prefixes.local.colors"); return getConfig().getStringList("prefixes.local.colors");
} }
public String getLocalSeparatorText() { public String getLocalSeparatorText() {
return getPrefixValue("local", "separator.text", " ▶ "); return getPrefixValue("local", "separator", " ▶ ");
} }
public String getLocalSeparatorColor() { public String getLocalSeparatorColor() {
return getPrefixValue("local", "separator.color", "#FFFFFF"); return getPrefixValue("local", "separator.color", "#FFFFFF");
@ -117,16 +117,16 @@ public class AppearanceConfig extends BaseConfig {
// ========== НАСТРОЙКИ ЧАТА ========== // ========== НАСТРОЙКИ ЧАТА ==========
public int getLocalRadius() { public int getLocalRadius() {
return getConfig().getInt("chat.local_radius", 100); return getConfig().getInt("chat.local-radius", 100);
} }
public int getClearLines() { public int getClearLines() {
return getConfig().getInt("chat.clear_lines", 100); return getConfig().getInt("chat.clear-lines", 100);
} }
public int getMinMessageLength() { public int getMinMessageLength() {
return getConfig().getInt("chat.min_message_length", 1); return getConfig().getInt("chat.min-message-length", 1);
} }
public int getMaxMessageLength() { public int getMaxMessageLength() {
return getConfig().getInt("chat.max_message_length", 2000); return getConfig().getInt("chat.max-message-length", 2000);
} }
private String getChatColor(String type, String defaultColor) { private String getChatColor(String type, String defaultColor) {
@ -134,7 +134,7 @@ public class AppearanceConfig extends BaseConfig {
} }
public String getNobodyHeardColor() { public String getNobodyHeardColor() {
return getChatColor("nobody_heard", "#FFA726"); return getChatColor("nobody-heard", "#FFA726");
} }
public String getErrorColor() { public String getErrorColor() {
return getChatColor("error", "#FF6B6B"); return getChatColor("error", "#FF6B6B");
@ -152,18 +152,18 @@ public class AppearanceConfig extends BaseConfig {
// ========== DISCORD ========== // ========== DISCORD ==========
public String getDiscordEmoji(String event) { public String getDiscordEmoji(String event) {
return getConfig().getString("discord.emojis." + event, ""); return getConfig().getString("discord.emojis." + event.replace('_', '-'), "");
} }
public String getDiscordEventTitle(String event) { public String getDiscordEventTitle(String event) {
return getConfig().getString("discord.event_titles." + event, ""); return getConfig().getString("discord.event-titles." + event.replace('_', '-'), "");
} }
public String getDefaultAvatarUrl() { public String getDefaultAvatarUrl() {
return getConfig().getString("discord.avatar_urls.default", "https://mc-heads.net/avatar/minecraft/64"); return getConfig().getString("discord.avatar-urls.default", "https://mc-heads.net/avatar/minecraft/64");
} }
public String getPlayerAvatarUrl() { public String getPlayerAvatarUrl() {
return getConfig().getString("discord.avatar_urls.player", "https://mc-heads.net/avatar/{player}/64"); return getConfig().getString("discord.avatar-urls.player", "https://mc-heads.net/avatar/{player}/64");
} }
public String getDefaultEmbedColor() { public String getDefaultEmbedColor() {
return getConfig().getString("discord.default_embed_color", "5865F2"); return getConfig().getString("discord.default-embed-color", "5865F2");
} }
} }

View file

@ -20,7 +20,7 @@ public class FiltersConfig extends BaseConfig {
} }
public int getCapsMaxPercent() { public int getCapsMaxPercent() {
return getConfig().getInt("caps.max-percent", 70); return getConfig().getInt("caps.max-caps-percent", 70);
} }
public int getCapsMinLength() { public int getCapsMinLength() {
@ -28,11 +28,11 @@ public class FiltersConfig extends BaseConfig {
} }
public boolean isCapsAutoLowercase() { public boolean isCapsAutoLowercase() {
return getConfig().getBoolean("caps.auto-lowercase", true); return true;
} }
public boolean isCapsBlock() { public boolean isCapsBlock() {
return getConfig().getBoolean("caps.block", false); return false;
} }
// ── Мат ───────────────────────────────────────────────────────────────────── // ── Мат ─────────────────────────────────────────────────────────────────────
@ -42,11 +42,11 @@ public class FiltersConfig extends BaseConfig {
} }
public String getSwearAction() { public String getSwearAction() {
return getConfig().getString("swear.action", "replace"); return getConfig().getBoolean("swear.replace", true) ? "replace" : "block";
} }
public String getSwearReplacementChar() { public String getSwearReplacementChar() {
return getConfig().getString("swear.replacement-char", "*"); return getConfig().getString("swear.replacement", "*");
} }
public boolean isSwearCheckFragments() { public boolean isSwearCheckFragments() {
@ -58,7 +58,7 @@ public class FiltersConfig extends BaseConfig {
} }
public boolean isSwearUseExternalFile() { public boolean isSwearUseExternalFile() {
return getConfig().getBoolean("swear.use-external-file", true); return true;
} }
public List<String> getSwearWords() { public List<String> getSwearWords() {
@ -72,27 +72,27 @@ public class FiltersConfig extends BaseConfig {
// ── Реклама ───────────────────────────────────────────────────────────────── // ── Реклама ─────────────────────────────────────────────────────────────────
public boolean isAdvertisingEnabled() { public boolean isAdvertisingEnabled() {
return getConfig().getBoolean("advertising.enabled", true); return getConfig().getBoolean("hidden-urls.enabled", true);
} }
public boolean isBlockHiddenUrls() { public boolean isBlockHiddenUrls() {
return getConfig().getBoolean("advertising.block-hidden-urls", true); return getConfig().getBoolean("hidden-urls.enabled", true);
} }
public boolean isBlockDomains() { public boolean isBlockDomains() {
return getConfig().getBoolean("advertising.block-domains", true); return true;
} }
public List<String> getWhitelistedDomains() { public List<String> getWhitelistedDomains() {
return getConfig().getStringList("advertising.whitelist"); return List.of();
} }
public List<String> getBlacklistedDomains() { public List<String> getBlacklistedDomains() {
return getConfig().getStringList("advertising.blacklist"); return List.of();
} }
public String getAdvertisingBlockMessage() { public String getAdvertisingBlockMessage() {
return getConfig().getString("advertising.blocked-message", "&#CF6679Реклама запрещена!"); return "&#CF6679Реклама запрещена!";
} }
// ── IP адреса ─────────────────────────────────────────────────────────────── // ── IP адреса ───────────────────────────────────────────────────────────────
@ -102,7 +102,7 @@ public class FiltersConfig extends BaseConfig {
} }
public boolean isIpBlock() { public boolean isIpBlock() {
return getConfig().getBoolean("ip.block", false); return false;
} }
public String getIpReplacement() { public String getIpReplacement() {
@ -116,11 +116,11 @@ public class FiltersConfig extends BaseConfig {
// ── Повторяющиеся символы ─────────────────────────────────────────────────── // ── Повторяющиеся символы ───────────────────────────────────────────────────
public boolean isRepeatEnabled() { public boolean isRepeatEnabled() {
return getConfig().getBoolean("repeat.enabled", true); return getConfig().getBoolean("spam.enabled", true);
} }
public int getRepeatMaxRepeats() { public int getRepeatMaxRepeats() {
return getConfig().getInt("repeat.max-repeats", 3); return getConfig().getInt("spam.max-repeating-chars", 3);
} }
// ── Флуд ──────────────────────────────────────────────────────────────────── // ── Флуд ────────────────────────────────────────────────────────────────────
@ -130,11 +130,11 @@ public class FiltersConfig extends BaseConfig {
} }
public int getFloodMaxMessages() { public int getFloodMaxMessages() {
return getConfig().getInt("flood.max-messages", 5); return getConfig().getInt("flood.max-same-messages", 3);
} }
public int getFloodTimePeriod() { public int getFloodTimePeriod() {
return getConfig().getInt("flood.time-period", 10); return 10;
} }
public String getFloodBlockMessage() { public String getFloodBlockMessage() {
@ -148,11 +148,11 @@ public class FiltersConfig extends BaseConfig {
} }
public int getSpamMaxSimilarMessages() { public int getSpamMaxSimilarMessages() {
return getConfig().getInt("spam.max-similar-messages", 3); return 3;
} }
public int getSpamSimilarityThreshold() { public int getSpamSimilarityThreshold() {
return getConfig().getInt("spam.similarity-threshold", 80); return 80;
} }
public String getSpamBlockMessage() { public String getSpamBlockMessage() {

View file

@ -14,48 +14,48 @@ public class MuteConfig extends BaseConfig {
// ── Основные настройки ────────────────────────────────────────────────────── // ── Основные настройки ──────────────────────────────────────────────────────
public boolean isEnabled() { public boolean isEnabled() {
return getConfig().getBoolean("enabled", true); return getConfig().getBoolean("settings.auto-unmute", true);
} }
public String getDefaultDuration() { public String getDefaultDuration() {
return getConfig().getString("default-duration", "7d"); return getConfig().getString("settings.default-duration", "7d");
} }
public String getDefaultReason() { public String getDefaultReason() {
return getConfig().getString("default-reason", "Без причины"); return getConfig().getString("settings.default-reason", "No reason specified");
} }
public int getMaxHistoryPerPlayer() { public int getMaxHistoryPerPlayer() {
return getConfig().getInt("max-history-per-player", 50); return getConfig().getInt("settings.max-history-entries", 50);
} }
// ── Права на длительность ─────────────────────────────────────────────────── // ── Права на длительность ───────────────────────────────────────────────────
public boolean isPermissionBasedDuration() { public boolean isPermissionBasedDuration() {
return getConfig().getBoolean("permission-based-duration", true); return true;
} }
// ── Уведомления ───────────────────────────────────────────────────────────── // ── Уведомления ─────────────────────────────────────────────────────────────
public boolean isNotifyOnMute() { public boolean isNotifyOnMute() {
return getConfig().getBoolean("notify.on-mute", true); return getConfig().getBoolean("notifications.public-mute", true);
} }
public boolean isNotifyOnUnmute() { public boolean isNotifyOnUnmute() {
return getConfig().getBoolean("notify.on-unmute", true); return getConfig().getBoolean("notifications.public-unmute", true);
} }
public boolean isNotifyOnExpire() { public boolean isNotifyOnExpire() {
return getConfig().getBoolean("notify.on-expire", true); return true;
} }
// ── Тихий мут ─────────────────────────────────────────────────────────────── // ── Тихий мут ───────────────────────────────────────────────────────────────
public boolean isSilentMuteEnabled() { public boolean isSilentMuteEnabled() {
return getConfig().getBoolean("silent-mute.enabled", true); return getConfig().getBoolean("notifications.silent-mute.enabled", true);
} }
public boolean isSilentMuteLogToConsole() { public boolean isSilentMuteLogToConsole() {
return getConfig().getBoolean("silent-mute.log-to-console", true); return true;
} }
} }

View file

@ -14,54 +14,54 @@ public class SoundsConfig extends BaseConfig {
// ── Звуки упоминаний ──────────────────────────────────────────────────────── // ── Звуки упоминаний ────────────────────────────────────────────────────────
public boolean isMentionSoundEnabled() { public boolean isMentionSoundEnabled() {
return getConfig().getBoolean("mention.enabled", true); return getConfig().getBoolean("messages.mention.enabled", true);
} }
public String getMentionSound() { public String getMentionSound() {
return getConfig().getString("mention.sound", "BLOCK_NOTE_BLOCK_PLING"); return getConfig().getString("messages.mention.sound", "BLOCK_NOTE_BLOCK_PLING");
} }
public float getMentionVolume() { public float getMentionVolume() {
return (float) getConfig().getDouble("mention.volume", 1.0); return (float) getConfig().getDouble("messages.mention.volume", 1.0);
} }
public float getMentionPitch() { public float getMentionPitch() {
return (float) getConfig().getDouble("mention.pitch", 1.0); return (float) getConfig().getDouble("messages.mention.pitch", 1.0);
} }
// ── Звуки личных сообщений ────────────────────────────────────────────────── // ── Звуки личных сообщений ──────────────────────────────────────────────────
public boolean isPmSoundEnabled() { public boolean isPmSoundEnabled() {
return getConfig().getBoolean("pm.enabled", true); return getConfig().getBoolean("messages.private-message.enabled", true);
} }
public String getPmSound() { public String getPmSound() {
return getConfig().getString("pm.sound", "ENTITY_EXPERIENCE_ORB_PICKUP"); return getConfig().getString("messages.private-message.sound", "ENTITY_EXPERIENCE_ORB_PICKUP");
} }
public float getPmVolume() { public float getPmVolume() {
return (float) getConfig().getDouble("pm.volume", 1.0); return (float) getConfig().getDouble("messages.private-message.volume", 1.0);
} }
public float getPmPitch() { public float getPmPitch() {
return (float) getConfig().getDouble("pm.pitch", 1.0); return (float) getConfig().getDouble("messages.private-message.pitch", 1.0);
} }
// ── Звуки команд ──────────────────────────────────────────────────────────── // ── Звуки команд ────────────────────────────────────────────────────────────
public boolean isCommandSoundEnabled() { public boolean isCommandSoundEnabled() {
return getConfig().getBoolean("command.enabled", false); return getConfig().getBoolean("commands.success.enabled", false);
} }
public String getCommandSound() { public String getCommandSound() {
return getConfig().getString("command.sound", "UI_BUTTON_CLICK"); return getConfig().getString("commands.success.sound", "UI_BUTTON_CLICK");
} }
public float getCommandVolume() { public float getCommandVolume() {
return (float) getConfig().getDouble("command.volume", 0.5); return (float) getConfig().getDouble("commands.success.volume", 0.5);
} }
public float getCommandPitch() { public float getCommandPitch() {
return (float) getConfig().getDouble("command.pitch", 1.0); return (float) getConfig().getDouble("commands.success.pitch", 1.0);
} }
} }

View file

@ -106,6 +106,7 @@ public class PluginInitializer {
MessagingService messagingService, NickService nickService, MessagingService messagingService, NickService nickService,
PunishmentService punishmentService) { PunishmentService punishmentService) {
AdvancedMessageFilter filter = new AdvancedMessageFilter(plugin.getConfig(), plugin); AdvancedMessageFilter filter = new AdvancedMessageFilter(plugin.getConfig(), plugin);
plugin.setChatFilter(filter);
plugin.getServer().getPluginManager().registerEvents( plugin.getServer().getPluginManager().registerEvents(
new ChatEventListener(plugin, messageService, playerService, filter), plugin new ChatEventListener(plugin, messageService, playerService, filter), plugin

View file

@ -1,5 +1,7 @@
package com.loki.lochat.core.filter; package com.loki.lochat.core.filter;
import com.loki.lochat.LoChat;
import com.loki.lochat.config.FiltersConfig;
import com.loki.lochat.core.filter.filters.CharacterFilter; import com.loki.lochat.core.filter.filters.CharacterFilter;
import com.loki.lochat.core.filter.filters.FloodFilter; import com.loki.lochat.core.filter.filters.FloodFilter;
import com.loki.lochat.core.filter.filters.SpamFilter; import com.loki.lochat.core.filter.filters.SpamFilter;
@ -17,7 +19,7 @@ import java.util.UUID;
*/ */
public class AdvancedMessageFilter { public class AdvancedMessageFilter {
private final FileConfiguration config; private final FiltersConfig filtersConfig;
private final CapsFilter capsFilter; private final CapsFilter capsFilter;
private final SwearFilter swearFilter; private final SwearFilter swearFilter;
private final FloodFilter floodFilter; private final FloodFilter floodFilter;
@ -25,39 +27,32 @@ public class AdvancedMessageFilter {
private final UrlFilter urlFilter; private final UrlFilter urlFilter;
private final CharacterFilter characterFilter; private final CharacterFilter characterFilter;
public AdvancedMessageFilter(FileConfiguration config) {
this(config, null);
}
public AdvancedMessageFilter(FileConfiguration config, JavaPlugin plugin) { public AdvancedMessageFilter(FileConfiguration config, JavaPlugin plugin) {
this.config = config; this.filtersConfig = resolveFiltersConfig(plugin);
// Инициализация CapsFilter
com.loki.lochat.config.FiltersConfig filtersConfig = null;
if (plugin instanceof com.loki.lochat.LoChat loChat) {
filtersConfig = loChat.getConfigManager().getFiltersConfig();
}
if (filtersConfig != null) {
int maxCapsPercent = filtersConfig.getCapsMaxPercent(); int maxCapsPercent = filtersConfig.getCapsMaxPercent();
int minLength = filtersConfig.getCapsMinLength(); int minLength = filtersConfig.getCapsMinLength();
boolean autoLower = filtersConfig.isCapsAutoLowercase(); boolean autoLower = filtersConfig.isCapsAutoLowercase();
boolean blockCaps = filtersConfig.isCapsBlock(); boolean blockCaps = filtersConfig.isCapsBlock();
this.capsFilter = new CapsFilter(maxCapsPercent, minLength, autoLower, blockCaps); this.capsFilter = new CapsFilter(maxCapsPercent, minLength, autoLower, blockCaps);
} else {
int maxCapsPercent = config.getInt("filters.caps.max-percent", 70); this.swearFilter = new SwearFilter(filtersConfig, plugin);
int minLength = config.getInt("filters.caps.min-length", 5); this.floodFilter = new FloodFilter(filtersConfig);
boolean autoLower = config.getBoolean("filters.caps.auto-lowercase", true); this.spamFilter = new SpamFilter(filtersConfig);
boolean blockCaps = config.getBoolean("filters.caps.block", false); this.urlFilter = new UrlFilter(filtersConfig);
this.capsFilter = new CapsFilter(maxCapsPercent, minLength, autoLower, blockCaps); this.characterFilter = new CharacterFilter(filtersConfig);
} }
// Инициализация остальных фильтров private static FiltersConfig resolveFiltersConfig(JavaPlugin plugin) {
this.swearFilter = new SwearFilter(config, plugin); if (plugin instanceof LoChat loChat) {
this.floodFilter = new FloodFilter(config); return loChat.getConfigManager().getFiltersConfig();
this.spamFilter = new SpamFilter(config); }
this.urlFilter = new UrlFilter(config); if (plugin != null) {
this.characterFilter = new CharacterFilter(config); FiltersConfig filtersConfig = new FiltersConfig(plugin);
filtersConfig.init();
return filtersConfig;
}
throw new IllegalArgumentException("AdvancedMessageFilter requires a FiltersConfig");
} }
/** /**
@ -74,7 +69,7 @@ public class AdvancedMessageFilter {
public FilterResult filterMessage(Player player, String message) { public FilterResult filterMessage(Player player, String message) {
// 1. Капс // 1. Капс
if (config.getBoolean("filters.caps.enabled", true)) { if (filtersConfig.isCapsEnabled()) {
String filtered = capsFilter.filter(player, message); String filtered = capsFilter.filter(player, message);
if (filtered == null) { if (filtered == null) {
return FilterResult.blocked("&#CF6679Не кричите в чате"); return FilterResult.blocked("&#CF6679Не кричите в чате");
@ -83,7 +78,7 @@ public class AdvancedMessageFilter {
} }
// 2. Мат // 2. Мат
if (config.getBoolean("filters.swear.enabled", true) && !canBypassFilter(player, "swear")) { if (filtersConfig.isSwearEnabled() && !canBypassFilter(player, "swear")) {
FilterResult swear = swearFilter.filter(player, message); FilterResult swear = swearFilter.filter(player, message);
if (!swear.allowed()) { if (!swear.allowed()) {
return swear; return swear;
@ -92,7 +87,7 @@ public class AdvancedMessageFilter {
} }
// 3. URL фильтры // 3. URL фильтры
if (config.getBoolean("filters.advertising.enabled", true)) { if (filtersConfig.isAdvertisingEnabled()) {
FilterResult r = urlFilter.filterUrls(player, message); FilterResult r = urlFilter.filterUrls(player, message);
if (!r.allowed()) { if (!r.allowed()) {
return r; return r;
@ -106,7 +101,7 @@ public class AdvancedMessageFilter {
} }
// 4. IP // 4. IP
if (config.getBoolean("filters.ip.enabled", true)) { if (filtersConfig.isIpEnabled()) {
FilterResult r = urlFilter.filterIPs(player, message); FilterResult r = urlFilter.filterIPs(player, message);
if (!r.allowed()) { if (!r.allowed()) {
return r; return r;
@ -115,12 +110,12 @@ public class AdvancedMessageFilter {
} }
// 5. Повторяющиеся символы // 5. Повторяющиеся символы
if (config.getBoolean("filters.repeat.enabled", true)) { if (filtersConfig.isRepeatEnabled()) {
message = characterFilter.filter(message); message = characterFilter.filter(message);
} }
// 6. Anti-flood // 6. Anti-flood
if (config.getBoolean("filters.flood.enabled", true) && !canBypassFilter(player, "flood")) { if (filtersConfig.isFloodEnabled() && !canBypassFilter(player, "flood")) {
FilterResult r = floodFilter.filter(player); FilterResult r = floodFilter.filter(player);
if (!r.allowed()) { if (!r.allowed()) {
return r; return r;
@ -128,7 +123,7 @@ public class AdvancedMessageFilter {
} }
// 7. Anti-spam // 7. Anti-spam
if (config.getBoolean("filters.spam.enabled", true) && !canBypassFilter(player, "spam")) { if (filtersConfig.isSpamEnabled() && !canBypassFilter(player, "spam")) {
FilterResult r = spamFilter.filter(player, message); FilterResult r = spamFilter.filter(player, message);
if (!r.allowed()) { if (!r.allowed()) {
return r; return r;

View file

@ -1,6 +1,6 @@
package com.loki.lochat.core.filter.filters; package com.loki.lochat.core.filter.filters;
import org.bukkit.configuration.file.FileConfiguration; import com.loki.lochat.config.FiltersConfig;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -8,9 +8,13 @@ public class CharacterFilter {
private final boolean enabled; private final boolean enabled;
private final int maxRepeating; private final int maxRepeating;
public CharacterFilter(FileConfiguration config) { public CharacterFilter(FiltersConfig filters) {
this.enabled = config.getBoolean("filters.repeating-chars.enabled", true); this(filters.isRepeatEnabled(), filters.getRepeatMaxRepeats());
this.maxRepeating = config.getInt("filters.repeating-chars.max", 3); }
public CharacterFilter(boolean enabled, int maxRepeating) {
this.enabled = enabled;
this.maxRepeating = maxRepeating;
} }
public String filter(String message) { public String filter(String message) {

View file

@ -1,8 +1,8 @@
package com.loki.lochat.core.filter.filters; package com.loki.lochat.core.filter.filters;
import com.loki.lochat.config.FiltersConfig;
import com.loki.lochat.core.filter.FilterResult; import com.loki.lochat.core.filter.FilterResult;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.util.ArrayDeque; import java.util.ArrayDeque;
@ -12,11 +12,15 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
public class FloodFilter { public class FloodFilter {
private final FileConfiguration config; private static final String FLOOD_BLOCK_MESSAGE = "&#CF6679Не флудите!";
private final int maxMessages;
private final int timePeriod;
private final Map<UUID, Deque<Long>> floodTracker = new ConcurrentHashMap<>(); private final Map<UUID, Deque<Long>> floodTracker = new ConcurrentHashMap<>();
public FloodFilter(FileConfiguration config) { public FloodFilter(FiltersConfig filters) {
this.config = config; this.maxMessages = filters.getFloodMaxMessages();
this.timePeriod = filters.getFloodTimePeriod();
} }
public FilterResult filter(Player player) { public FilterResult filter(Player player) {
@ -24,8 +28,6 @@ public class FloodFilter {
return FilterResult.ok(null); return FilterResult.ok(null);
} }
int maxMessages = config.getInt("filters.flood.max-messages", 5);
int timePeriod = config.getInt("filters.flood.time-period", 10);
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
long windowMs = timePeriod * 1000L; long windowMs = timePeriod * 1000L;
@ -38,8 +40,7 @@ public class FloodFilter {
} }
if (timestamps.size() >= maxMessages) { if (timestamps.size() >= maxMessages) {
return FilterResult.blocked(config.getString("filters.flood.block-message", return FilterResult.blocked(FLOOD_BLOCK_MESSAGE);
"&#CF6679Не флудите!"));
} }
timestamps.addLast(now); timestamps.addLast(now);

View file

@ -1,8 +1,8 @@
package com.loki.lochat.core.filter.filters; package com.loki.lochat.core.filter.filters;
import com.loki.lochat.config.FiltersConfig;
import com.loki.lochat.core.filter.FilterResult; import com.loki.lochat.core.filter.FilterResult;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.util.ArrayDeque; import java.util.ArrayDeque;
@ -12,11 +12,20 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
public class SpamFilter { public class SpamFilter {
private final FileConfiguration config; private final int maxSimilar;
private final int threshold;
private final String blockMessage;
private final Map<UUID, Deque<String>> spamTracker = new ConcurrentHashMap<>(); private final Map<UUID, Deque<String>> spamTracker = new ConcurrentHashMap<>();
public SpamFilter(FileConfiguration config) { public SpamFilter(FiltersConfig filters) {
this.config = config; this(filters.getSpamMaxSimilarMessages(), filters.getSpamSimilarityThreshold(),
filters.getSpamBlockMessage());
}
public SpamFilter(int maxSimilar, int threshold, String blockMessage) {
this.maxSimilar = maxSimilar;
this.threshold = threshold;
this.blockMessage = blockMessage;
} }
public FilterResult filter(Player player, String message) { public FilterResult filter(Player player, String message) {
@ -24,9 +33,6 @@ public class SpamFilter {
return FilterResult.ok(message); return FilterResult.ok(message);
} }
int maxSimilar = config.getInt("filters.spam.max-similar-messages", 3);
int threshold = config.getInt("filters.spam.similarity-threshold", 80);
Deque<String> history = spamTracker.computeIfAbsent( Deque<String> history = spamTracker.computeIfAbsent(
player.getUniqueId(), k -> new ArrayDeque<>()); player.getUniqueId(), k -> new ArrayDeque<>());
@ -36,8 +42,7 @@ public class SpamFilter {
.count(); .count();
if (similarCount >= maxSimilar) { if (similarCount >= maxSimilar) {
return FilterResult.blocked(config.getString("filters.spam.block-message", return FilterResult.blocked(blockMessage);
"&#CF6679Не отправляйте одинаковые сообщения"));
} }
// Добавляем в историю (храним последние 10) // Добавляем в историю (храним последние 10)

View file

@ -1,9 +1,9 @@
package com.loki.lochat.core.filter.filters; package com.loki.lochat.core.filter.filters;
import com.loki.lochat.config.FiltersConfig;
import com.loki.lochat.core.filter.FilterResult; import com.loki.lochat.core.filter.FilterResult;
import com.loki.lochat.utils.text.AhoCorasick; import com.loki.lochat.utils.text.AhoCorasick;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@ -20,10 +20,10 @@ public class SwearFilter {
private final String replacement; private final String replacement;
private AhoCorasick ahoCorasick; private AhoCorasick ahoCorasick;
public SwearFilter(FileConfiguration config, JavaPlugin plugin) { public SwearFilter(FiltersConfig filters, JavaPlugin plugin) {
this.enabled = config.getBoolean("filters.swear.enabled", true); this.enabled = filters.isSwearEnabled();
this.replaceMode = config.getBoolean("filters.swear.replace", false); this.replaceMode = "replace".equals(filters.getSwearAction());
this.replacement = config.getString("filters.swear.replacement", "***"); this.replacement = filters.getSwearReplacementChar();
if (enabled) { if (enabled) {
loadWordList(plugin); loadWordList(plugin);

View file

@ -1,8 +1,8 @@
package com.loki.lochat.core.filter.filters; package com.loki.lochat.core.filter.filters;
import com.loki.lochat.config.FiltersConfig;
import com.loki.lochat.core.filter.FilterResult; import com.loki.lochat.core.filter.FilterResult;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.util.HashSet; import java.util.HashSet;
@ -23,14 +23,23 @@ public class UrlFilter {
"(?i)(h\\s*t\\s*t\\s*p|w\\s*w\\s*w|d\\s*o\\s*t\\s*c\\s*o\\s*m)" "(?i)(h\\s*t\\s*t\\s*p|w\\s*w\\s*w|d\\s*o\\s*t\\s*c\\s*o\\s*m)"
); );
private final FileConfiguration config; private static final String URL_BLOCKED_MESSAGE = "&#CF6679Ссылки запрещены!";
private static final String URL_BLACKLISTED_MESSAGE = "&#CF6679Эта ссылка запрещена!";
private static final String IP_BLOCKED_MESSAGE = "&#CF6679IP адреса запрещены!";
private static final String HIDDEN_URL_BLOCKED_MESSAGE = "&#CF6679Реклама запрещена!";
private final boolean blockHiddenUrls;
private final boolean blockIp;
private final String ipReplacement;
private final Set<String> whitelistedDomains; private final Set<String> whitelistedDomains;
private final Set<String> blacklistedDomains; private final Set<String> blacklistedDomains;
public UrlFilter(FileConfiguration config) { public UrlFilter(FiltersConfig filters) {
this.config = config; this.blockHiddenUrls = filters.isBlockHiddenUrls();
this.whitelistedDomains = new HashSet<>(config.getStringList("filters.advertising.whitelist")); this.blockIp = filters.isIpBlock();
this.blacklistedDomains = new HashSet<>(config.getStringList("filters.advertising.blacklist")); this.ipReplacement = filters.getIpReplacement();
this.whitelistedDomains = new HashSet<>(filters.getWhitelistedDomains());
this.blacklistedDomains = new HashSet<>(filters.getBlacklistedDomains());
} }
public FilterResult filterUrls(Player player, String message) { public FilterResult filterUrls(Player player, String message) {
@ -46,8 +55,7 @@ public class UrlFilter {
boolean ok = whitelistedDomains.stream() boolean ok = whitelistedDomains.stream()
.anyMatch(d -> domain.toLowerCase().contains(d.toLowerCase())); .anyMatch(d -> domain.toLowerCase().contains(d.toLowerCase()));
if (!ok) { if (!ok) {
return FilterResult.blocked(config.getString("filters.url.blocked-message", return FilterResult.blocked(URL_BLOCKED_MESSAGE);
"&#CF6679Ссылки запрещены!"));
} }
} }
@ -55,8 +63,7 @@ public class UrlFilter {
boolean bad = blacklistedDomains.stream() boolean bad = blacklistedDomains.stream()
.anyMatch(d -> domain.toLowerCase().contains(d.toLowerCase())); .anyMatch(d -> domain.toLowerCase().contains(d.toLowerCase()));
if (bad) { if (bad) {
return FilterResult.blocked(config.getString("filters.url.blocked-message", return FilterResult.blocked(URL_BLACKLISTED_MESSAGE);
"&#CF6679Эта ссылка запрещена!"));
} }
} }
} }
@ -71,19 +78,17 @@ public class UrlFilter {
Matcher matcher = IP_PATTERN.matcher(message); Matcher matcher = IP_PATTERN.matcher(message);
if (matcher.find()) { if (matcher.find()) {
if (config.getBoolean("filters.ip.block", false)) { if (blockIp) {
return FilterResult.blocked(config.getString("filters.ip.blocked-message", return FilterResult.blocked(IP_BLOCKED_MESSAGE);
"&#CF6679IP адреса запрещены!"));
} }
return FilterResult.ok(matcher.replaceAll( return FilterResult.ok(matcher.replaceAll(ipReplacement));
config.getString("filters.ip.replacement", "[IP скрыт]")));
} }
return FilterResult.ok(message); return FilterResult.ok(message);
} }
public FilterResult filterHiddenUrls(Player player, String message) { public FilterResult filterHiddenUrls(Player player, String message) {
if (!config.getBoolean("filters.advertising.block-hidden-urls", true)) { if (!blockHiddenUrls) {
return FilterResult.ok(message); return FilterResult.ok(message);
} }
if (player.hasPermission("lochat.bypass.hiddenurls")) { if (player.hasPermission("lochat.bypass.hiddenurls")) {
@ -91,8 +96,7 @@ public class UrlFilter {
} }
if (HIDDEN_URL_PATTERN.matcher(message).find()) { if (HIDDEN_URL_PATTERN.matcher(message).find()) {
return FilterResult.blocked(config.getString("filters.advertising.blocked-message", return FilterResult.blocked(HIDDEN_URL_BLOCKED_MESSAGE);
"&#CF6679Реклама запрещена!"));
} }
return FilterResult.ok(message); return FilterResult.ok(message);

View file

@ -47,8 +47,10 @@ public class ServiceRegistry {
PlayerService playerService = new PlayerServiceImpl(plugin); PlayerService playerService = new PlayerServiceImpl(plugin);
register(PlayerService.class, playerService); register(PlayerService.class, playerService);
register(ChatService.class, new ChatServiceImpl(plugin)); MessageService messageService = new MessageServiceImpl(plugin, muteService, playerService);
register(MessageService.class, new MessageServiceImpl(plugin, muteService, playerService)); register(MessageService.class, messageService);
register(ChatService.class, new ChatServiceImpl(plugin, messageService));
PrivateMessageServiceImpl pmService = new PrivateMessageServiceImpl((LoChat) plugin); PrivateMessageServiceImpl pmService = new PrivateMessageServiceImpl((LoChat) plugin);
MessagingService messagingService = ServiceFactory.createMessagingService(plugin, messageConfig, pmService); MessagingService messagingService = ServiceFactory.createMessagingService(plugin, messageConfig, pmService);

View file

@ -1,6 +1,11 @@
package com.loki.lochat.core.service; package com.loki.lochat.core.service;
import com.loki.lochat.LoChat;
import com.loki.lochat.api.service.ChatService; import com.loki.lochat.api.service.ChatService;
import com.loki.lochat.api.service.MessageService;
import com.loki.lochat.config.AppearanceConfig;
import com.loki.lochat.core.filter.AdvancedMessageFilter;
import com.loki.lochat.core.filter.FilterResult;
import com.loki.lochat.renderer.EnhancedChatRenderer; import com.loki.lochat.renderer.EnhancedChatRenderer;
import com.loki.lochat.utils.format.ChatFormatter; import com.loki.lochat.utils.format.ChatFormatter;
import com.loki.lochat.utils.persistence.FilePersistence; import com.loki.lochat.utils.persistence.FilePersistence;
@ -8,6 +13,7 @@ import com.loki.lochat.utils.platform.FoliaUtil;
import com.loki.lochat.utils.player.DistanceUtil; import com.loki.lochat.utils.player.DistanceUtil;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.FileConfiguration;
@ -26,10 +32,12 @@ import java.util.concurrent.ConcurrentHashMap;
public class ChatServiceImpl implements ChatService { public class ChatServiceImpl implements ChatService {
private final JavaPlugin plugin; private final JavaPlugin plugin;
private final MessageService messageService;
private final Set<UUID> globalChatDisabled = ConcurrentHashMap.newKeySet(); private final Set<UUID> globalChatDisabled = ConcurrentHashMap.newKeySet();
public ChatServiceImpl(JavaPlugin plugin) { public ChatServiceImpl(JavaPlugin plugin, MessageService messageService) {
this.plugin = plugin; this.plugin = plugin;
this.messageService = messageService;
loadDisabled(); loadDisabled();
} }
@ -39,7 +47,10 @@ public class ChatServiceImpl implements ChatService {
@Override @Override
public void sendGlobalMessage(Player sender, Object message) { public void sendGlobalMessage(Player sender, Object message) {
Component msg = toComponent(message); Component msg = toComponent(sender, message, true);
if (msg == null) {
return;
}
EnhancedChatRenderer renderer = new EnhancedChatRenderer(plugin, true); EnhancedChatRenderer renderer = new EnhancedChatRenderer(plugin, true);
for (Player p : Bukkit.getOnlinePlayers()) { for (Player p : Bukkit.getOnlinePlayers()) {
if (!isGlobalChatDisabled(p.getUniqueId())) { if (!isGlobalChatDisabled(p.getUniqueId())) {
@ -50,8 +61,11 @@ public class ChatServiceImpl implements ChatService {
@Override @Override
public void sendLocalMessage(Player sender, Object message) { public void sendLocalMessage(Player sender, Object message) {
int radius = plugin.getConfig().getInt("chat.local.radius", 100); int radius = ((LoChat) plugin).getConfigManager().getAppearanceConfig().getLocalRadius();
Component msg = toComponent(message); Component msg = toComponent(sender, message, false);
if (msg == null) {
return;
}
EnhancedChatRenderer renderer = new EnhancedChatRenderer(plugin, false); EnhancedChatRenderer renderer = new EnhancedChatRenderer(plugin, false);
int count = 0; int count = 0;
@ -112,7 +126,38 @@ public class ChatServiceImpl implements ChatService {
FilePersistence.saveYaml(plugin, "data/players.yml", cfg); FilePersistence.saveYaml(plugin, "data/players.yml", cfg);
} }
private Component toComponent(Object message) { private Component toComponent(Player sender, Object message, boolean isGlobal) {
if (!(message instanceof String text)) {
return message instanceof Component c ? c : ChatFormatter.parse(message.toString()); return message instanceof Component c ? c : ChatFormatter.parse(message.toString());
} }
AppearanceConfig appearance = ((LoChat) plugin).getConfigManager().getAppearanceConfig();
int minLength = appearance.getMinMessageLength();
int maxLength = appearance.getMaxMessageLength();
if (text.length() < minLength || text.length() > maxLength) {
sender.sendMessage(ChatFormatter.parse(
"&#CF6679Сообщение должно содержать от " + minLength + " до " + maxLength + " символов"));
return null;
}
if (!messageService.processMessage(sender, isGlobal ? "!" + text : text)) {
return null;
}
AdvancedMessageFilter filter = ((LoChat) plugin).getChatFilter();
if (filter != null) {
FilterResult result = filter.filterMessage(sender, text);
if (!result.allowed()) {
sender.sendMessage(ChatFormatter.parse(result.blockReason()));
return null;
}
text = result.filteredMessage();
}
String safe = MiniMessage.miniMessage().escapeTags(text);
if (sender.hasPermission("lochat.chat.colors")) {
return ChatFormatter.parse(safe);
}
return Component.text(safe);
}
} }

View file

@ -32,6 +32,7 @@ public class PlayerServiceImpl implements PlayerService {
private final AtomicLong totalMessages = new AtomicLong(0); private final AtomicLong totalMessages = new AtomicLong(0);
private FileConfiguration stats; private FileConfiguration stats;
private final Object statsLock = new Object();
public PlayerServiceImpl(JavaPlugin plugin) { public PlayerServiceImpl(JavaPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
@ -113,6 +114,7 @@ public class PlayerServiceImpl implements PlayerService {
@Override @Override
public void saveAll() { public void saveAll() {
synchronized (statsLock) {
stats.set("global.total-messages", stats.set("global.total-messages",
stats.getLong("global.total-messages", 0) + totalMessages.getAndSet(0)); stats.getLong("global.total-messages", 0) + totalMessages.getAndSet(0));
stats.set("by-type.global-chat", stats.set("by-type.global-chat",
@ -124,15 +126,18 @@ public class PlayerServiceImpl implements PlayerService {
stats.set("last-updated", System.currentTimeMillis()); stats.set("last-updated", System.currentTimeMillis());
FilePersistence.saveYaml(plugin, "data/statistics.yml", stats); FilePersistence.saveYaml(plugin, "data/statistics.yml", stats);
} }
}
// ========== Private Methods ========== // ========== Private Methods ==========
private void persistPlayerMessages(UUID uuid, long count) { private void persistPlayerMessages(UUID uuid, long count) {
synchronized (statsLock) {
String path = "top-players.by-messages." + uuid; String path = "top-players.by-messages." + uuid;
long prev = stats.getLong(path + ".count", 0); long prev = stats.getLong(path + ".count", 0);
stats.set(path + ".count", prev + count); stats.set(path + ".count", prev + count);
stats.set(path + ".last-updated", System.currentTimeMillis()); stats.set(path + ".last-updated", System.currentTimeMillis());
FilePersistence.saveYaml(plugin, "data/statistics.yml", stats); FilePersistence.saveYaml(plugin, "data/statistics.yml", stats);
}
playerMessages.remove(uuid); playerMessages.remove(uuid);
} }

View file

@ -94,7 +94,6 @@ public class PunishmentServiceImpl implements PunishmentService {
} }
if (b.isExpired()) { if (b.isExpired()) {
bans.remove(uuid, b); bans.remove(uuid, b);
saveAsync();
return false; return false;
} }
return true; return true;

View file

@ -4,16 +4,15 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import com.loki.lochat.data.model.MuteData; import com.loki.lochat.data.model.MuteData;
import com.loki.lochat.utils.persistence.FilePersistence;
import com.loki.lochat.utils.platform.FoliaUtil; import com.loki.lochat.utils.platform.FoliaUtil;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import java.io.File; import java.io.File;
import java.io.FileReader; import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException; import java.io.IOException;
import java.io.Reader; import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -87,27 +86,12 @@ public class MuteDataStorage {
} }
private void save() { private void save() {
try { Map<String, MuteData> toSave = new HashMap<>();
ensureDataFolderExists();
Map<String, MuteData> toSave = new ConcurrentHashMap<>();
mutes.forEach((uuid, data) -> toSave.put(uuid.toString(), data)); mutes.forEach((uuid, data) -> toSave.put(uuid.toString(), data));
FilePersistence.saveJson(plugin, "mutes.json", toSave, gson);
try (Writer writer = new FileWriter(dataFile)) {
gson.toJson(toSave, writer);
}
} catch (IOException e) {
plugin.getLogger().warning("Error saving mutes: " + e.getMessage());
}
} }
private void saveAsync() { private void saveAsync() {
FoliaUtil.runAsync(plugin, this::save); FoliaUtil.runAsync(plugin, this::save);
} }
private void ensureDataFolderExists() {
if (!dataFile.getParentFile().exists()) {
dataFile.getParentFile().mkdirs();
}
}
} }

View file

@ -7,19 +7,28 @@ import com.loki.lochat.config.RatConfig;
import com.loki.lochat.data.model.MuteData; import com.loki.lochat.data.model.MuteData;
import java.io.File; import java.io.File;
import java.io.FileOutputStream;
import java.io.FileReader; import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
public class MuteHistoryManager { public class MuteHistoryManager {
private static final ConcurrentHashMap<String, ReentrantLock> FILE_LOCKS = new ConcurrentHashMap<>();
private final Map<UUID, List<MuteData.MuteHistoryEntry>> history = new ConcurrentHashMap<>(); private final Map<UUID, List<MuteData.MuteHistoryEntry>> history = new ConcurrentHashMap<>();
private final File historyFile; private final File historyFile;
private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
@ -86,10 +95,35 @@ public class MuteHistoryManager {
} }
public void save() { public void save() {
try (FileWriter writer = new FileWriter(historyFile)) { ReentrantLock lock = FILE_LOCKS.computeIfAbsent(
historyFile.getAbsolutePath(), k -> new ReentrantLock());
lock.lock();
try {
File parent = historyFile.getParentFile();
if (parent != null && !parent.exists()) {
parent.mkdirs();
}
File tmp = parent != null
? new File(parent, historyFile.getName() + ".tmp")
: new File(historyFile.getName() + ".tmp");
try (Writer writer = new OutputStreamWriter(
new FileOutputStream(tmp), StandardCharsets.UTF_8)) {
gson.toJson(history, writer); gson.toJson(history, writer);
}
moveIntoPlace(tmp);
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); System.err.println("Failed to save mute history: " + e.getMessage());
} finally {
lock.unlock();
}
}
private void moveIntoPlace(File tmp) throws IOException {
try {
Files.move(tmp.toPath(), historyFile.toPath(),
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp.toPath(), historyFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
} }
} }

View file

@ -12,11 +12,9 @@ public class GradientConfig {
private final JavaPlugin plugin; private final JavaPlugin plugin;
private FileConfiguration config; private FileConfiguration config;
private FileConfiguration mainConfig;
public GradientConfig(JavaPlugin plugin) { public GradientConfig(JavaPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
this.mainConfig = plugin.getConfig();
loadConfig(); loadConfig();
} }
@ -28,7 +26,6 @@ public class GradientConfig {
public void reload() { public void reload() {
plugin.reloadConfig(); plugin.reloadConfig();
this.mainConfig = plugin.getConfig();
this.config = plugin.getConfig(); this.config = plugin.getConfig();
} }
@ -42,56 +39,51 @@ public class GradientConfig {
} }
public int getMaxColors() { public int getMaxColors() {
return config.getInt("gradient.max-colors", 7); return config.getInt("gradient.limits.max-colors", 7);
} }
public int getMinColors() { public int getMinColors() {
return config.getInt("gradient.min-colors", 1); return config.getInt("gradient.limits.min-colors", 1);
} }
public int getPricePerColor() { public int getPricePerColor() {
return config.getInt("gradient.price-per-color", 50); return config.getInt("gradient.pricing.price-per-color", 50);
} }
public int getPrefixPrice() { public int getPrefixPrice() {
return config.getInt("gradient.prefix-price", 500); return config.getInt("gradient.pricing.prefix-price", 500);
} }
public boolean isPrefixOneTimePurchase() { public boolean isPrefixOneTimePurchase() {
return config.getBoolean("gradient.prefix-one-time-purchase", true); return config.getBoolean("gradient.pricing.prefix-one-time-purchase", true);
} }
public int getColorCooldown() { public int getColorCooldown() {
return config.getInt("gradient.color-cooldown", 60); return config.getInt("gradient.cooldowns.color", 60);
} }
public int getPrefixCooldown() { public int getPrefixCooldown() {
return config.getInt("gradient.prefix-cooldown", 120); return config.getInt("gradient.cooldowns.prefix", 120);
} }
public int getMaxPrefixLength() { public int getMaxPrefixLength() {
return config.getInt("gradient.max-prefix-length", 16); return config.getInt("gradient.limits.max-prefix-length", 16);
} }
public String getPrefixFormat() { public String getPrefixFormat() {
// Сначала проверяем в formatting.prefix-format (из gradient-config.yml) return config.getString("gradient.formatting.prefix-format", "[{prefix}] ");
if (config.contains("formatting.prefix-format")) {
return config.getString("formatting.prefix-format", "[{prefix}] ");
}
// Затем проверяем в gradient.prefix-format (из config.yml)
return config.getString("gradient.prefix-format", "[{prefix}] ");
} }
public boolean isGradientOnPrefix() { public boolean isGradientOnPrefix() {
return config.getBoolean("gradient.gradient-on-prefix", true); return config.getBoolean("gradient.formatting.gradient-on-prefix", true);
} }
public boolean isGradientOnLuckPermsPrefix() { public boolean isGradientOnLuckPermsPrefix() {
return config.getBoolean("gradient.gradient-on-luckperms-prefix", true); return config.getBoolean("gradient.formatting.gradient-on-luckperms-prefix", true);
} }
public boolean isContinuousGradient() { public boolean isContinuousGradient() {
return config.getBoolean("gradient.continuous-gradient", true); return config.getBoolean("gradient.formatting.continuous-gradient", true);
} }
public boolean isUseLegacyRgbFormat() { public boolean isUseLegacyRgbFormat() {
@ -99,15 +91,15 @@ public class GradientConfig {
} }
public String getStorageType() { public String getStorageType() {
return config.getString("gradient.storage-type", "file"); return config.getString("gradient.storage.type", "file");
} }
public String getSqliteFile() { public String getSqliteFile() {
return config.getString("gradient.sqlite-file", "gradient-data.db"); return config.getString("gradient.storage.sqlite-file", "gradient-data.db");
} }
public List<String> getPrefixBlacklist() { public List<String> getPrefixBlacklist() {
return config.getStringList("gradient.prefix-blacklist"); return List.of();
} }
public boolean isPrefixBlacklisted(String prefix) { public boolean isPrefixBlacklisted(String prefix) {
@ -117,16 +109,10 @@ public class GradientConfig {
} }
public boolean isUpdateDisplayName() { public boolean isUpdateDisplayName() {
if (mainConfig.contains("display.update-display-name")) { return config.getBoolean("gradient.display.update-display-name", true);
return mainConfig.getBoolean("display.update-display-name", true);
}
return config.getBoolean("display.update-display-name", true);
} }
public boolean isUpdateTabList() { public boolean isUpdateTabList() {
if (mainConfig.contains("display.update-tab-list")) { return config.getBoolean("gradient.display.update-tab-list", true);
return mainConfig.getBoolean("display.update-tab-list", true);
}
return config.getBoolean("display.update-tab-list", true);
} }
} }

View file

@ -52,7 +52,7 @@ public class DiscordConfig {
} }
public String getAvatarUrl() { public String getAvatarUrl() {
return config.getString("webhook.avatar_url", ""); return config.getString("webhook.avatar-url", "");
} }
public int getTimeout() { public int getTimeout() {
@ -60,11 +60,11 @@ public class DiscordConfig {
} }
public int getRetryAttempts() { public int getRetryAttempts() {
return config.getInt("performance.retry_attempts", 3); return config.getInt("performance.retry-attempts", 3);
} }
public long getRetryDelay() { public long getRetryDelay() {
return config.getLong("performance.retry_delay", 1000); return config.getLong("performance.retry-delay", 1000);
} }
public boolean isAsync() { public boolean isAsync() {
@ -84,11 +84,11 @@ public class DiscordConfig {
} }
public boolean isGlobalOnly() { public boolean isGlobalOnly() {
return config.getBoolean("chat.global_only", true); return config.getBoolean("chat.global-only", true);
} }
public boolean useEmbed() { public boolean useEmbed() {
return config.getBoolean("chat.use_embed", true); return config.getBoolean("chat.use-embed", true);
} }
public String getChatFormat() { public String getChatFormat() {
@ -96,7 +96,7 @@ public class DiscordConfig {
} }
public String getEmbedColor() { public String getEmbedColor() {
return config.getString("chat.embed_color", "5865F2"); return config.getString("chat.embed-color", "5865F2");
} }
public boolean isEventEnabled(String event) { public boolean isEventEnabled(String event) {
@ -121,14 +121,14 @@ public class DiscordConfig {
} }
public int getMinMessageLength() { public int getMinMessageLength() {
return config.getInt("filter.min_message_length", 1); return config.getInt("filter.min-message-length", 1);
} }
public int getMaxMessageLength() { public int getMaxMessageLength() {
return config.getInt("filter.max_message_length", 2000); return config.getInt("filter.max-message-length", 2000);
} }
public boolean sanitizeMentions() { public boolean sanitizeMentions() {
return config.getBoolean("filter.sanitize_mentions", true); return config.getBoolean("filter.sanitize-mentions", true);
} }
} }

View file

@ -12,8 +12,6 @@ import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerQuitEvent;
import io.papermc.paper.event.player.AsyncChatEvent;
/** /**
* Слушатель событий для Discord интеграции * Слушатель событий для Discord интеграции
*/ */
@ -24,29 +22,6 @@ public class DiscordEventListener implements Listener {
this.discord = discord; this.discord = discord;
} }
/**
* Обработка сообщений чата для Discord
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChat(AsyncChatEvent event) {
if (!discord.isEnabled()) {
return;
}
Player player = event.getPlayer();
String message = PlainTextComponentSerializer.plainText().serialize(event.message());
// Определяем тип чата (глобальный если начинается с !)
boolean isGlobal = message.startsWith("!");
// Очищаем сообщение от ! если это глобальный чат
if (isGlobal) {
message = message.substring(1).stripLeading();
}
discord.sendChatMessage(player, message, isGlobal);
}
/** /**
* Обработка входа игрока * Обработка входа игрока
*/ */

View file

@ -44,11 +44,11 @@ public class AutoMessageManager {
// Настройки // Настройки
prefix = plugin.getConfigManager().getString("prefix", "&#00D4FF[LoChat]"); prefix = plugin.getConfigManager().getString("prefix", "&#00D4FF[LoChat]");
mode = "sequential"; // Всегда последовательно mode = config.getBoolean("automessages.random", false) ? "random" : "sequential";
// Загружаем сообщения из config.yml // Загружаем сообщения из config.yml
messages = new LinkedHashMap<>(); messages = new LinkedHashMap<>();
ConfigurationSection msgSection = config.getConfigurationSection("automessages-list"); ConfigurationSection msgSection = config.getConfigurationSection("automessages.messages");
if (msgSection != null) { if (msgSection != null) {
for (String key : msgSection.getKeys(false)) { for (String key : msgSection.getKeys(false)) {

View file

@ -55,7 +55,12 @@ public class EnhancedChatRenderer implements ChatRenderer {
if (viewer instanceof Player vp) { if (viewer instanceof Player vp) {
processed = mentionHandler.processMentions(processed, source, vp); processed = mentionHandler.processMentions(processed, source, vp);
processed = applyTranslation(processed, vp); if (cfg.isTranslationEnabled()) {
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
if (loChat.getTranslationService().isEnabled()) {
sendTranslationAsync(vp, PlainTextComponentSerializer.plainText().serialize(processed));
}
}
} }
// Получаем формат из конфига // Получаем формат из конфига
@ -202,31 +207,26 @@ public class EnhancedChatRenderer implements ChatRenderer {
.clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " ")); .clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " "));
} }
private Component applyTranslation(Component message, Player viewer) { private void sendTranslationAsync(Player viewer, String plainText) {
if (!cfg.isTranslationEnabled()) { if (plainText.isBlank()) {
return message; return;
} }
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin; com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
TranslationService ts = loChat.getTranslationService(); TranslationService ts = loChat.getTranslationService();
if (!ts.isEnabled()) {
return message;
}
String plainText = PlainTextComponentSerializer.plainText().serialize(message);
if (plainText.isBlank()) {
return message;
}
String viewerLang = viewer.locale().getLanguage(); String viewerLang = viewer.locale().getLanguage();
String translated = ts.translate(plainText, viewerLang); ts.translateAsync(plainText, viewerLang)
.thenAccept(translated -> {
if (translated.equals(plainText)) { if (translated.equals(plainText)) {
return message; return;
} }
String langName = LANGUAGE_NAMES.getOrDefault(viewerLang, viewerLang.toUpperCase(java.util.Locale.ROOT)); String langName = LANGUAGE_NAMES.getOrDefault(viewerLang, viewerLang.toUpperCase(java.util.Locale.ROOT));
Component hover = Component.text() Component component = Component.text()
.append(com.loki.lochat.utils.format.ChatFormatter.parse( .append(com.loki.lochat.utils.format.ChatFormatter.parse(
"&#AAAAAA[" + langName + "]&#FFFFFF ")) "&#AAAAAA[" + langName + "]&#FFFFFF "))
.append(Component.text(translated)) .append(Component.text(translated))
.build(); .build();
return message.hoverEvent(HoverEvent.showText(hover)); viewer.sendActionBar(component);
});
} }
private static final Map<String, String> LANGUAGE_NAMES = new HashMap<>(); private static final Map<String, String> LANGUAGE_NAMES = new HashMap<>();

View file

@ -10,19 +10,26 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.logging.Logger; import java.util.logging.Logger;
public class TranslationService { public class TranslationService {
private static final Duration TIMEOUT = Duration.ofSeconds(10); private static final Duration TIMEOUT = Duration.ofSeconds(10);
private static final int CACHE_MAX_SIZE = 2000;
private final Logger logger; private final Logger logger;
private final HttpClient httpClient; private final HttpClient httpClient;
private final Gson gson; private final Gson gson;
private final Map<String, Map<String, String>> cache = new ConcurrentHashMap<>(); private final Map<String, Map<String, String>> cache;
private final ExecutorService executor;
private final String endpoint; private final String endpoint;
private final String apiKey; private final String apiKey;
private final boolean enabled; private final boolean enabled;
@ -36,39 +43,53 @@ public class TranslationService {
.connectTimeout(TIMEOUT) .connectTimeout(TIMEOUT)
.build(); .build();
this.gson = new Gson(); this.gson = new Gson();
this.cache = Collections.synchronizedMap(new LinkedHashMap<String, Map<String, String>>() {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Map<String, String>> eldest) {
return size() > CACHE_MAX_SIZE;
}
});
this.executor = Executors.newFixedThreadPool(2, new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "lochat-translation");
t.setDaemon(true);
return t;
}
});
} }
public boolean isEnabled() { public boolean isEnabled() {
return enabled && !endpoint.isBlank(); return enabled && !endpoint.isBlank();
} }
public String translate(String text, String targetLanguage) { public CompletableFuture<String> translateAsync(String text, String targetLanguage) {
if (!isEnabled() || text == null || text.isBlank()) { if (!isEnabled() || text == null || text.isBlank()) {
return text; return CompletableFuture.completedFuture(text);
} }
String langCode = localeToLanguageCode(targetLanguage); String langCode = localeToLanguageCode(targetLanguage);
String cacheKey = text.intern();
Map<String, String> langMap = cache.get(cacheKey); String cached = getCached(text, langCode);
if (langMap != null) {
String cached = langMap.get(langCode);
if (cached != null) { if (cached != null) {
return cached; return CompletableFuture.completedFuture(cached);
}
} }
return CompletableFuture.supplyAsync(() -> fetchTranslation(text, langCode), executor);
}
private String fetchTranslation(String text, String targetLang) {
try { try {
String translation = fetchTranslation(text, langCode); String translation = doFetch(text, targetLang);
cache.computeIfAbsent(cacheKey, k -> new ConcurrentHashMap<>()).put(langCode, translation); putCached(text, targetLang, translation);
return translation; return translation;
} catch (Exception e) { } catch (Exception e) {
logger.warning("Translation failed for \"" + abbreviate(text) + "\" to " + langCode + ": " + e.getMessage()); logger.warning("Translation failed for \"" + abbreviate(text) + "\" to " + targetLang + ": " + e.getMessage());
return text; return text;
} }
} }
private String fetchTranslation(String text, String targetLang) throws Exception { private String doFetch(String text, String targetLang) throws Exception {
JsonObject body = new JsonObject(); JsonObject body = new JsonObject();
body.addProperty("q", text); body.addProperty("q", text);
body.addProperty("source", "auto"); body.addProperty("source", "auto");
@ -95,6 +116,27 @@ public class TranslationService {
return json.get("translatedText").getAsString(); return json.get("translatedText").getAsString();
} }
private String getCached(String text, String langCode) {
synchronized (cache) {
Map<String, String> langMap = cache.get(text);
if (langMap != null) {
return langMap.get(langCode);
}
}
return null;
}
private void putCached(String text, String langCode, String translation) {
synchronized (cache) {
Map<String, String> langMap = cache.get(text);
if (langMap == null) {
langMap = new LinkedHashMap<>();
cache.put(text, langMap);
}
langMap.put(langCode, translation);
}
}
public static String localeToLanguageCode(String locale) { public static String localeToLanguageCode(String locale) {
if (locale == null || locale.isBlank()) { if (locale == null || locale.isBlank()) {
return "en"; return "en";

View file

@ -15,9 +15,16 @@ import java.io.OutputStreamWriter;
import java.io.Reader; import java.io.Reader;
import java.io.Writer; import java.io.Writer;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
public final class FilePersistence { public final class FilePersistence {
private static final ConcurrentHashMap<String, ReentrantLock> FILE_LOCKS = new ConcurrentHashMap<>();
private FilePersistence() {} private FilePersistence() {}
public static File getFile(JavaPlugin plugin, String name) { public static File getFile(JavaPlugin plugin, String name) {
@ -34,12 +41,18 @@ public final class FilePersistence {
} }
public static void saveYaml(JavaPlugin plugin, String name, FileConfiguration config) { public static void saveYaml(JavaPlugin plugin, String name, FileConfiguration config) {
File target = getFile(plugin, name);
ReentrantLock lock = lockFor(target);
lock.lock();
try { try {
File file = getFile(plugin, name); target.getParentFile().mkdirs();
file.getParentFile().mkdirs(); File tmp = new File(target.getParentFile(), target.getName() + ".tmp");
config.save(file); config.save(tmp);
moveIntoPlace(tmp, target);
} catch (IOException e) { } catch (IOException e) {
plugin.getLogger().warning("Failed to save " + name + ": " + e.getMessage()); plugin.getLogger().warning("Failed to save " + name + ": " + e.getMessage());
} finally {
lock.unlock();
} }
} }
@ -57,14 +70,33 @@ public final class FilePersistence {
} }
public static void saveJson(JavaPlugin plugin, String name, Object data, Gson gson) { public static void saveJson(JavaPlugin plugin, String name, Object data, Gson gson) {
File target = getFile(plugin, name);
ReentrantLock lock = lockFor(target);
lock.lock();
try { try {
File file = getFile(plugin, name); target.getParentFile().mkdirs();
file.getParentFile().mkdirs(); File tmp = new File(target.getParentFile(), target.getName() + ".tmp");
try (Writer writer = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) { try (Writer writer = new OutputStreamWriter(new FileOutputStream(tmp), StandardCharsets.UTF_8)) {
gson.toJson(data, writer); gson.toJson(data, writer);
} }
moveIntoPlace(tmp, target);
} catch (IOException e) { } catch (IOException e) {
plugin.getLogger().warning("Failed to save " + name + ": " + e.getMessage()); plugin.getLogger().warning("Failed to save " + name + ": " + e.getMessage());
} finally {
lock.unlock();
}
}
private static ReentrantLock lockFor(File target) {
return FILE_LOCKS.computeIfAbsent(target.getAbsolutePath(), k -> new ReentrantLock());
}
private static void moveIntoPlace(File tmp, File target) throws IOException {
try {
Files.move(tmp.toPath(), target.toPath(),
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING);
} }
} }
} }

View file

@ -218,3 +218,15 @@ messages:
hub-command: hub-command:
enabled: true enabled: true
server: "hub" server: "hub"
# ── Auto Messages ──────────────────────────────────────────────────────────────
automessages:
enabled: true
interval: 300
random: false
messages:
welcome:
- "&7Добро пожаловать на сервер! &b%online%&7 игроков онлайн"
vote:
- "&7Голосуй за сервер и получай награды!"
- "&7Ссылка: example.com/vote"

View file

@ -2,78 +2,57 @@ package com.loki.lochat.core.filter;
import com.loki.lochat.core.filter.filters.CharacterFilter; import com.loki.lochat.core.filter.filters.CharacterFilter;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertEquals;
class CharacterFilterTest { class CharacterFilterTest {
@Test @Test
void filter_limitsRepeatingChars() { void filter_limitsRepeatingChars() {
YamlConfiguration config = new YamlConfiguration(); CharacterFilter filter = new CharacterFilter(true, 3);
config.set("filters.repeating-chars.enabled", true);
config.set("filters.repeating-chars.max", 3);
CharacterFilter filter = new CharacterFilter(config);
assertEquals("привееет", filter.filter("привеееееет")); assertEquals("привееет", filter.filter("привеееееет"));
} }
@Test @Test
void filter_noChangeWhenUnderLimit() { void filter_noChangeWhenUnderLimit() {
YamlConfiguration config = new YamlConfiguration(); CharacterFilter filter = new CharacterFilter(true, 3);
config.set("filters.repeating-chars.enabled", true);
config.set("filters.repeating-chars.max", 3);
CharacterFilter filter = new CharacterFilter(config);
assertEquals("привет", filter.filter("привет")); assertEquals("привет", filter.filter("привет"));
} }
@Test @Test
void filter_shortRepeatsAllowed() { void filter_shortRepeatsAllowed() {
YamlConfiguration config = new YamlConfiguration(); CharacterFilter filter = new CharacterFilter(true, 3);
config.set("filters.repeating-chars.enabled", true);
config.set("filters.repeating-chars.max", 3);
CharacterFilter filter = new CharacterFilter(config);
assertEquals("ууупс", filter.filter("ууупс")); assertEquals("ууупс", filter.filter("ууупс"));
} }
@Test @Test
void filter_disabledReturnsOriginal() { void filter_disabledReturnsOriginal() {
YamlConfiguration config = new YamlConfiguration(); CharacterFilter filter = new CharacterFilter(false, 3);
config.set("filters.repeating-chars.enabled", false);
CharacterFilter filter = new CharacterFilter(config);
assertEquals("мноооого букв", filter.filter("мноооого букв")); assertEquals("мноооого букв", filter.filter("мноооого букв"));
} }
@Test @Test
void filter_zeroMaxDisabled() { void filter_zeroMaxDisabled() {
YamlConfiguration config = new YamlConfiguration(); CharacterFilter filter = new CharacterFilter(true, 0);
config.set("filters.repeating-chars.enabled", true);
config.set("filters.repeating-chars.max", 0);
CharacterFilter filter = new CharacterFilter(config);
assertEquals("теееекст", filter.filter("теееекст")); assertEquals("теееекст", filter.filter("теееекст"));
} }
@Test @Test
void filter_defaultConfig() { void filter_defaultConfig() {
CharacterFilter filter = new CharacterFilter(new YamlConfiguration()); CharacterFilter filter = new CharacterFilter(true, 3);
assertEquals("тееест", filter.filter("теееест")); assertEquals("тееест", filter.filter("теееест"));
} }
@Test @Test
void filter_differentMax() { void filter_differentMax() {
YamlConfiguration config = new YamlConfiguration(); CharacterFilter filter = new CharacterFilter(true, 1);
config.set("filters.repeating-chars.enabled", true);
config.set("filters.repeating-chars.max", 1);
CharacterFilter filter = new CharacterFilter(config);
assertEquals("ап", filter.filter("ааап")); assertEquals("ап", filter.filter("ааап"));
} }
@Test @Test
void filter_multiplePatterns() { void filter_multiplePatterns() {
YamlConfiguration config = new YamlConfiguration(); CharacterFilter filter = new CharacterFilter(true, 2);
config.set("filters.repeating-chars.enabled", true);
config.set("filters.repeating-chars.max", 2);
CharacterFilter filter = new CharacterFilter(config);
assertEquals("aa bb", filter.filter("aaa bbb")); assertEquals("aa bb", filter.filter("aaa bbb"));
} }
} }

View file

@ -2,12 +2,16 @@ package com.loki.lochat.core.filter;
import com.loki.lochat.core.filter.filters.SpamFilter; import com.loki.lochat.core.filter.filters.SpamFilter;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; import java.util.UUID;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class SpamFilterTest { class SpamFilterTest {
@ -15,11 +19,8 @@ class SpamFilterTest {
void filter_firstMessageAllowed() { void filter_firstMessageAllowed() {
Player player = mock(Player.class); Player player = mock(Player.class);
when(player.hasPermission("lochat.bypass.spam")).thenReturn(false); when(player.hasPermission("lochat.bypass.spam")).thenReturn(false);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(player.getUniqueId()).thenReturn(UUID.randomUUID());
YamlConfiguration config = new YamlConfiguration(); SpamFilter filter = new SpamFilter(3, 80, "no spam");
config.set("filters.spam.max-similar-messages", 3);
config.set("filters.spam.similarity-threshold", 80);
SpamFilter filter = new SpamFilter(config);
FilterResult result = filter.filter(player, "hello"); FilterResult result = filter.filter(player, "hello");
assertTrue(result.allowed()); assertTrue(result.allowed());
} }
@ -28,12 +29,8 @@ class SpamFilterTest {
void filter_sameMessageRepeated() { void filter_sameMessageRepeated() {
Player player = mock(Player.class); Player player = mock(Player.class);
when(player.hasPermission("lochat.bypass.spam")).thenReturn(false); when(player.hasPermission("lochat.bypass.spam")).thenReturn(false);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(player.getUniqueId()).thenReturn(UUID.randomUUID());
YamlConfiguration config = new YamlConfiguration(); SpamFilter filter = new SpamFilter(2, 80, "no spam");
config.set("filters.spam.max-similar-messages", 2);
config.set("filters.spam.similarity-threshold", 80);
config.set("filters.spam.block-message", "no spam");
SpamFilter filter = new SpamFilter(config);
filter.filter(player, "hello"); filter.filter(player, "hello");
filter.filter(player, "hello"); filter.filter(player, "hello");
FilterResult result = filter.filter(player, "hello"); FilterResult result = filter.filter(player, "hello");
@ -45,11 +42,8 @@ class SpamFilterTest {
void filter_differentMessagesAllowed() { void filter_differentMessagesAllowed() {
Player player = mock(Player.class); Player player = mock(Player.class);
when(player.hasPermission("lochat.bypass.spam")).thenReturn(false); when(player.hasPermission("lochat.bypass.spam")).thenReturn(false);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(player.getUniqueId()).thenReturn(UUID.randomUUID());
YamlConfiguration config = new YamlConfiguration(); SpamFilter filter = new SpamFilter(2, 80, "no spam");
config.set("filters.spam.max-similar-messages", 2);
config.set("filters.spam.similarity-threshold", 80);
SpamFilter filter = new SpamFilter(config);
filter.filter(player, "hello"); filter.filter(player, "hello");
filter.filter(player, "world"); filter.filter(player, "world");
FilterResult result = filter.filter(player, "foo"); FilterResult result = filter.filter(player, "foo");
@ -60,9 +54,8 @@ class SpamFilterTest {
void filter_bypassPermission() { void filter_bypassPermission() {
Player player = mock(Player.class); Player player = mock(Player.class);
when(player.hasPermission("lochat.bypass.spam")).thenReturn(true); when(player.hasPermission("lochat.bypass.spam")).thenReturn(true);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(player.getUniqueId()).thenReturn(UUID.randomUUID());
YamlConfiguration config = new YamlConfiguration(); SpamFilter filter = new SpamFilter(3, 80, "no spam");
SpamFilter filter = new SpamFilter(config);
FilterResult result = filter.filter(player, "spam"); FilterResult result = filter.filter(player, "spam");
assertTrue(result.allowed()); assertTrue(result.allowed());
} }
@ -71,12 +64,8 @@ class SpamFilterTest {
void filter_historyLimit() { void filter_historyLimit() {
Player player = mock(Player.class); Player player = mock(Player.class);
when(player.hasPermission("lochat.bypass.spam")).thenReturn(false); when(player.hasPermission("lochat.bypass.spam")).thenReturn(false);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(player.getUniqueId()).thenReturn(UUID.randomUUID());
YamlConfiguration config = new YamlConfiguration(); SpamFilter filter = new SpamFilter(2, 100, "no spam");
config.set("filters.spam.max-similar-messages", 2);
config.set("filters.spam.similarity-threshold", 100);
config.set("filters.spam.block-message", "no spam");
SpamFilter filter = new SpamFilter(config);
// Send 12 different messages to fill and rotate history // Send 12 different messages to fill and rotate history
for (int i = 0; i < 12; i++) { for (int i = 0; i < 12; i++) {
filter.filter(player, "msg" + i); filter.filter(player, "msg" + i);
@ -92,12 +81,8 @@ class SpamFilterTest {
void filter_similarButNotExact() { void filter_similarButNotExact() {
Player player = mock(Player.class); Player player = mock(Player.class);
when(player.hasPermission("lochat.bypass.spam")).thenReturn(false); when(player.hasPermission("lochat.bypass.spam")).thenReturn(false);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(player.getUniqueId()).thenReturn(UUID.randomUUID());
YamlConfiguration config = new YamlConfiguration(); SpamFilter filter = new SpamFilter(2, 90, "no spam");
config.set("filters.spam.max-similar-messages", 2);
config.set("filters.spam.similarity-threshold", 90);
config.set("filters.spam.block-message", "no spam");
SpamFilter filter = new SpamFilter(config);
filter.filter(player, "hello world"); filter.filter(player, "hello world");
filter.filter(player, "hello world"); filter.filter(player, "hello world");
FilterResult result = filter.filter(player, "hello world!"); FilterResult result = filter.filter(player, "hello world!");
@ -108,13 +93,10 @@ class SpamFilterTest {
@Test @Test
void clearPlayer_removesData() { void clearPlayer_removesData() {
Player player = mock(Player.class); Player player = mock(Player.class);
java.util.UUID uuid = java.util.UUID.randomUUID(); UUID uuid = UUID.randomUUID();
when(player.hasPermission("lochat.bypass.spam")).thenReturn(false); when(player.hasPermission("lochat.bypass.spam")).thenReturn(false);
when(player.getUniqueId()).thenReturn(uuid); when(player.getUniqueId()).thenReturn(uuid);
YamlConfiguration config = new YamlConfiguration(); SpamFilter filter = new SpamFilter(2, 100, "no spam");
config.set("filters.spam.max-similar-messages", 2);
config.set("filters.spam.similarity-threshold", 100);
SpamFilter filter = new SpamFilter(config);
filter.filter(player, "hello"); filter.filter(player, "hello");
filter.clearPlayer(uuid); filter.clearPlayer(uuid);
filter.filter(player, "hello"); filter.filter(player, "hello");