feat: chat channel system (/join /leave /channel)

- config-driven channels in config/channels.yml (global, local, staff, trade) with
  emoji, colors, prefix, radius, cooldowns, permissions and aliases
- Channel record + ChannelConfig loader wired into ConfigManager (reload supported)
- ChannelService (api) + ConcurrentHashMap-backed ChannelServiceImpl, joined in
  ServiceRegistry; default channel join treated as 'not in channel'
- JoinChannelCommand / LeaveChannelCommand / ChannelCommand (+ tab completion),
  registered in CommandManager; plugin.yml commands + permissions
- ChatEventListener routes AsyncChatEvent to the active channel: per-channel
  cooldown, local-radius viewer filtering, channel-aware renderer
- EnhancedChatRenderer supports Channel overload (emojiFor/prefixText/channelColor)
- ChannelServiceImplTest covering join/leave/permissions/aliases
This commit is contained in:
loki5512344 2026-08-09 21:31:11 +02:00
parent 689ab1a9ae
commit a822c91637
Signed by: boba
GPG key ID: 253067914055423B
17 changed files with 821 additions and 12 deletions

11
TODO.md
View file

@ -66,11 +66,12 @@
## 🟡 NEXT: NEW FEATURES ## 🟡 NEXT: NEW FEATURES
### 1. Channels (like VentureChat / Carbon) ### 1. Channels (like VentureChat / Carbon) ✅
- `/join`, `/leave`, `/channel` — full channel system: global, local, staff, trade, party. - `/join`, `/leave`, `/channel` — full channel system: global, local, staff, trade.
- Per-channel colors, aliases, permissions, cooldowns, spy mode. - Config-driven channels via `config/channels.yml` (emoji, colors, prefix, radius, cooldowns, permissions, aliases).
- Party chat: private groups `/party`. - Chat routing: `ChatEventListener` sends to the joined channel; local radius filtering; per-channel cooldown; channel-aware `EnhancedChatRenderer`.
- Network channels (Bungee/Velocity) sync. - Party chat: private groups `/party` (future).
- Network channels (Bungee/Velocity) sync (future).
### 2. Anti-swear hardening + filter statistics ✅ ### 2. Anti-swear hardening + filter statistics ✅
- Obfuscation bypass: `SwearNormalizer` (q→р / leet-подстановки, повторы букв «сууука», разделители внутри слова «с.у.к.а» / «с у к а») + tolerant-маскирование в replace-режиме. Single source of truth для Aho-Corasick. - Obfuscation bypass: `SwearNormalizer` (q→р / leet-подстановки, повторы букв «сууука», разделители внутри слова «с.у.к.а» / «с у к а») + tolerant-маскирование в replace-режиме. Single source of truth для Aho-Corasick.

View file

@ -0,0 +1,40 @@
package com.loki.lochat.api.service;
import com.loki.lochat.channel.Channel;
import org.bukkit.entity.Player;
import java.util.Collection;
import java.util.Optional;
/**
* Сервис каналов чата: состояние игроков (в каком канале они состоят).
*/
public interface ChannelService {
boolean isEnabled();
Channel getDefault();
Collection<Channel> getChannels();
Channel find(String nameOrAlias);
/**
* Активный (не-дефолтный) канал игрока, если он его присоединил через /join.
*/
Optional<Channel> getActive(Player player);
boolean isInChannel(Player player);
boolean canUse(Player player, Channel channel);
/**
* Переводит игрока в канал. Дефолтный канал трактуется как «не присоединён».
*
* @return true если присоединение выполнено, false если нет канала или нет прав
*/
boolean join(Player player, Channel channel);
void leave(Player player);
}

View file

@ -0,0 +1,33 @@
package com.loki.lochat.channel;
import java.util.List;
/**
* Канал чата (global, local, staff, trade...).
* Иммутабельное описание канала из config/channels.yml.
*/
public record Channel(
String id,
String display,
String prefix,
String color,
int radius,
int cooldown,
String permission,
List<String> aliases) {
/** radius &lt; 1 — глобальный канал (видны всем игрокам) */
public boolean isGlobalScope() {
return radius < 1;
}
/** radius &gt; 0 — локальный канал с радиусом действия */
public boolean isLocalScope() {
return radius > 0;
}
/** Есть ли требование по праву для входа/использования */
public boolean hasPermission() {
return permission != null && !permission.isBlank();
}
}

View file

@ -8,7 +8,10 @@ import com.loki.lochat.commands.admin.chat.ClearChatCommand;
import com.loki.lochat.commands.admin.chat.ClearChatConfigCommand; import com.loki.lochat.commands.admin.chat.ClearChatConfigCommand;
import com.loki.lochat.commands.admin.system.LoChatCommand; import com.loki.lochat.commands.admin.system.LoChatCommand;
import com.loki.lochat.commands.admin.system.ReloadConfigCommand; import com.loki.lochat.commands.admin.system.ReloadConfigCommand;
import com.loki.lochat.commands.chat.ChannelCommand;
import com.loki.lochat.commands.chat.GlobalChatCommand; import com.loki.lochat.commands.chat.GlobalChatCommand;
import com.loki.lochat.commands.chat.JoinChannelCommand;
import com.loki.lochat.commands.chat.LeaveChannelCommand;
import com.loki.lochat.commands.chat.LocalChatCommand; import com.loki.lochat.commands.chat.LocalChatCommand;
import com.loki.lochat.commands.messaging.IgnoreCommand; import com.loki.lochat.commands.messaging.IgnoreCommand;
import com.loki.lochat.commands.messaging.IgnoreListCommand; import com.loki.lochat.commands.messaging.IgnoreListCommand;
@ -54,6 +57,11 @@ public class CommandManager {
reg("g", new GlobalChatCommand(plugin)); reg("g", new GlobalChatCommand(plugin));
reg("l", new LocalChatCommand(plugin)); reg("l", new LocalChatCommand(plugin));
// ── Каналы ─────────────────────────────────────────────────────────────
regTab("join", new JoinChannelCommand(plugin));
reg("leave", new LeaveChannelCommand(plugin));
regTab("channel", new ChannelCommand(plugin));
// ── Личные сообщения / игнор ────────────────────────────────────────── // ── Личные сообщения / игнор ──────────────────────────────────────────
reg("msg", new MsgCommand(plugin)); reg("msg", new MsgCommand(plugin));
reg("reply", new ReplyCommand(plugin)); reg("reply", new ReplyCommand(plugin));

View file

@ -0,0 +1,120 @@
package com.loki.lochat.commands.chat;
import com.loki.lochat.LoChat;
import com.loki.lochat.api.service.ChannelService;
import com.loki.lochat.channel.Channel;
import com.loki.lochat.commands.base.PlayerCommand;
import com.loki.lochat.utils.format.ChatFormatter;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Locale;
/**
* /channel — инфо о текущем канале со списком доступных.
* /channel &lt;канал&gt; — переключиться. /channel off — выйти.
* /channel list — список каналов.
*/
public class ChannelCommand extends PlayerCommand {
public ChannelCommand(LoChat plugin) {
super(plugin);
}
@Override
protected boolean executePlayerCommand(@NotNull Player player, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class);
if (!channels.isEnabled()) {
player.sendMessage(ChatFormatter.parse("&#CF6679Система каналов отключена"));
return true;
}
if (args.length == 0) {
showCurrent(player, channels);
return true;
}
switch (args[0].toLowerCase(Locale.ROOT)) {
case "off", "leave" -> {
if (channels.isInChannel(player)) {
channels.leave(player);
player.sendMessage(ChatFormatter.parse("&#9878C9Вы вышли из канала"));
} else {
player.sendMessage(ChatFormatter.parse("&#B798A8Вы не находитесь в канале"));
}
}
case "list" -> listChannels(player, channels);
default -> joinChannel(player, channels, args[0]);
}
return true;
}
private void showCurrent(Player player, ChannelService channels) {
Channel active = channels.getActive(player).orElse(null);
if (active == null) {
Channel def = channels.getDefault();
player.sendMessage(ChatFormatter.parse("&#B798A8Вы в дефолтном канале [&#7858E9"
+ (def == null ? "?" : def.display() + "&8 " + def.prefix()) + "&#B798A8]"));
} else {
player.sendMessage(ChatFormatter.parse("&#9878C9Вы в канале [&#7858E9"
+ active.display() + "&8 " + active.prefix() + "&#9878C9]"));
}
player.sendMessage(ChatFormatter.parse("&#B798A8/join <канал> &#9878C9— войти, "
+ "&#B798A8/channel list &#9878C9— все каналы"));
}
private void listChannels(Player player, ChannelService channels) {
player.sendMessage(ChatFormatter.parse("&#B798A8Доступные каналы:"));
for (Channel c : channels.getChannels()) {
boolean canUse = channels.canUse(player, c);
boolean active = c.equals(channels.getActive(player).orElse(null));
player.sendMessage(ChatFormatter.parse((canUse ? "&#9878C9" : "&#CF6679")
+ (active ? "\u25B8 " : "\u25AA ") + c.display() + "&8 " + c.prefix()
+ (canUse ? "" : " &#CF6679(нет прав)")
+ " &#B798A8— " + (c.isGlobalScope() ? "глобальный" : "локальный, " + c.radius() + "б")));
}
}
private void joinChannel(Player player, ChannelService channels, String name) {
Channel channel = channels.find(name);
if (channel == null) {
player.sendMessage(ChatFormatter.parse("&#CF6679Канал &#7858E9" + name + " &#CF6679не найден"));
return;
}
if (!channels.canUse(player, channel)) {
player.sendMessage(ChatFormatter.parse(
plugin.getConfigManager().getMessagesConfig().getNoPermission()));
return;
}
Channel active = channels.getActive(player).orElse(null);
channels.join(player, channel);
if (channel.equals(active)) {
player.sendMessage(ChatFormatter.parse("&#B798A8Вы уже в канале [&#7858E9"
+ channel.display() + "&8 " + channel.prefix() + "&#B798A8]"));
} else {
player.sendMessage(ChatFormatter.parse("&#9878C9Вы вошли в канал [&#7858E9"
+ channel.display() + "&8 " + channel.prefix() + "&#9878C9]"));
}
}
@Override
protected @Nullable List<String> getTabCompletions(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
if (args.length != 1) {
return null;
}
ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class);
String input = args[0].toLowerCase(Locale.ROOT);
return java.util.stream.Stream.concat(
java.util.stream.Stream.of("list", "off"),
channels.getChannels().stream().map(Channel::id))
.filter(s -> s.startsWith(input))
.toList();
}
}

View file

@ -0,0 +1,80 @@
package com.loki.lochat.commands.chat;
import com.loki.lochat.LoChat;
import com.loki.lochat.api.service.ChannelService;
import com.loki.lochat.channel.Channel;
import com.loki.lochat.commands.base.PlayerCommand;
import com.loki.lochat.utils.format.ChatFormatter;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Locale;
/**
* /join &lt;канал|алиас&gt; — войти в канал чата.
*/
public class JoinChannelCommand extends PlayerCommand {
public JoinChannelCommand(LoChat plugin) {
super(plugin);
}
@Override
protected boolean executePlayerCommand(@NotNull Player player, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
if (!plugin.getConfigManager().getChannelConfig().isEnabled()) {
player.sendMessage(ChatFormatter.parse("&#CF6679Система каналов отключена"));
return true;
}
if (args.length == 0) {
player.sendMessage(ChatFormatter.parse("&#CF6679Использование: &#7858E9/join <канал>"));
return true;
}
ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class);
Channel channel = channels.find(args[0]);
if (channel == null) {
player.sendMessage(ChatFormatter.parse("&#CF6679Канал &#7858E9" + args[0] + " &#CF6679не найден"));
return true;
}
if (!channels.canUse(player, channel)) {
player.sendMessage(ChatFormatter.parse(
plugin.getConfigManager().getMessagesConfig().getNoPermission()));
return true;
}
Channel active = channels.getActive(player).orElse(null);
channels.join(player, channel);
if (channel.equals(channels.getDefault())) {
player.sendMessage(ChatFormatter.parse("&#9878C9Вы в канале [&#7858E9"
+ channel.display() + "&8 " + channel.prefix() + "&#9878C9] (дефолтный)"));
} else if (channel.equals(active)) {
player.sendMessage(ChatFormatter.parse("&#B798A8Вы уже в канале [&#7858E9"
+ channel.display() + "&8 " + channel.prefix() + "&#B798A8]"));
} else {
player.sendMessage(ChatFormatter.parse("&#9878C9Вы вошли в канал [&#7858E9"
+ channel.display() + "&8 " + channel.prefix() + "&#9878C9]"));
}
return true;
}
@Override
protected @Nullable List<String> getTabCompletions(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
if (args.length != 1) {
return null;
}
ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class);
String input = args[0].toLowerCase(Locale.ROOT);
return channels.getChannels().stream()
.filter(c -> !(sender instanceof Player p) || channels.canUse(p, c))
.map(Channel::id)
.filter(id -> id.toLowerCase(Locale.ROOT).startsWith(input))
.toList();
}
}

View file

@ -0,0 +1,37 @@
package com.loki.lochat.commands.chat;
import com.loki.lochat.LoChat;
import com.loki.lochat.api.service.ChannelService;
import com.loki.lochat.commands.base.PlayerCommand;
import com.loki.lochat.utils.format.ChatFormatter;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
/**
* /leave — выйти из текущего канала (вернуться к дефолтному чату).
*/
public class LeaveChannelCommand extends PlayerCommand {
public LeaveChannelCommand(LoChat plugin) {
super(plugin);
}
@Override
protected boolean executePlayerCommand(@NotNull Player player, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class);
if (!channels.isEnabled()) {
player.sendMessage(ChatFormatter.parse("&#CF6679Система каналов отключена"));
return true;
}
if (!channels.isInChannel(player)) {
player.sendMessage(ChatFormatter.parse("&#B798A8Вы не находитесь в канале"));
return true;
}
channels.leave(player);
player.sendMessage(ChatFormatter.parse("&#9878C9Вы вышли из канала"));
return true;
}
}

View file

@ -0,0 +1,99 @@
package com.loki.lochat.config;
import com.loki.lochat.channel.Channel;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Конфигурация каналов чата (config/channels.yml).
*/
public class ChannelConfig extends BaseConfig {
private volatile Map<String, Channel> channels = new LinkedHashMap<>();
private volatile Map<String, Channel> byAlias = new LinkedHashMap<>();
private volatile Channel defaultChannel;
public ChannelConfig(JavaPlugin plugin) {
super(plugin, "channels.yml", true);
}
@Override
protected void onLoad() {
loadChannels();
buildAliasIndex();
}
private void loadChannels() {
Map<String, Channel> result = new LinkedHashMap<>();
ConfigurationSection section = getConfig().getConfigurationSection("channels.list");
if (section != null) {
for (String id : section.getKeys(false)) {
String path = "channels.list." + id;
result.put(id, new Channel(
id,
getConfig().getString(path + ".display", ""),
getConfig().getString(path + ".prefix", id.toUpperCase(Locale.ROOT)),
getConfig().getString(path + ".color", "&7"),
getConfig().getInt(path + ".radius", -1),
getConfig().getInt(path + ".cooldown", 0),
getConfig().getString(path + ".permission", ""),
getConfig().getStringList(path + ".aliases")));
}
}
this.channels = result;
}
private void buildAliasIndex() {
Map<String, Channel> index = new LinkedHashMap<>();
for (Channel channel : channels.values()) {
index.put(channel.id().toLowerCase(Locale.ROOT), channel);
for (String alias : channel.aliases()) {
if (alias == null || alias.isBlank()) {
continue;
}
index.putIfAbsent(alias.toLowerCase(Locale.ROOT), channel);
}
}
this.byAlias = index;
String defaultId = getConfig().getString("channels.default", "global");
this.defaultChannel = channels.get(defaultId);
if (defaultChannel == null) {
this.defaultChannel = channels.isEmpty() ? null : channels.values().iterator().next();
}
}
public boolean isEnabled() {
return getConfig().getBoolean("channels.enabled", true);
}
public Channel getDefault() {
return defaultChannel;
}
public Channel get(String id) {
return id == null ? null : channels.get(id.toLowerCase(Locale.ROOT));
}
/**
* Поиск канала по имени или алиасу (без учёта регистра).
*/
public Channel find(String name) {
return name == null ? null : byAlias.get(name.toLowerCase(Locale.ROOT));
}
public Collection<Channel> getChannels() {
return channels.values();
}
public List<Channel> getChannelList() {
return List.copyOf(channels.values());
}
}

View file

@ -21,6 +21,7 @@ public class ConfigManager {
private volatile MuteConfig muteConfig; private volatile MuteConfig muteConfig;
private volatile FiltersConfig filtersConfig; private volatile FiltersConfig filtersConfig;
private volatile SoundsConfig soundsConfig; private volatile SoundsConfig soundsConfig;
private volatile ChannelConfig channelConfig;
public ConfigManager(LoChat plugin) { public ConfigManager(LoChat plugin) {
this.plugin = plugin; this.plugin = plugin;
@ -33,6 +34,7 @@ public class ConfigManager {
this.muteConfig = new MuteConfig(plugin); this.muteConfig = new MuteConfig(plugin);
this.filtersConfig = new FiltersConfig(plugin); this.filtersConfig = new FiltersConfig(plugin);
this.soundsConfig = new SoundsConfig(plugin); this.soundsConfig = new SoundsConfig(plugin);
this.channelConfig = new ChannelConfig(plugin);
// Инициализируем все конфиги // Инициализируем все конфиги
this.appearanceConfig.init(); this.appearanceConfig.init();
@ -40,6 +42,7 @@ public class ConfigManager {
this.muteConfig.init(); this.muteConfig.init();
this.filtersConfig.init(); this.filtersConfig.init();
this.soundsConfig.init(); this.soundsConfig.init();
this.channelConfig.init();
loader.updateConfig(); loader.updateConfig();
} }
@ -66,6 +69,10 @@ public class ConfigManager {
return soundsConfig; return soundsConfig;
} }
public ChannelConfig getChannelConfig() {
return channelConfig;
}
// ========== Обновление и перезагрузка ========== // ========== Обновление и перезагрузка ==========
public synchronized void reload() { public synchronized void reload() {
@ -77,6 +84,7 @@ public class ConfigManager {
this.muteConfig.reload(); this.muteConfig.reload();
this.filtersConfig.reload(); this.filtersConfig.reload();
this.soundsConfig.reload(); this.soundsConfig.reload();
this.channelConfig.reload();
plugin.getLogger().info("Все конфигурации успешно перезагружены"); plugin.getLogger().info("Все конфигурации успешно перезагружены");
} }

View file

@ -109,7 +109,8 @@ public class PluginInitializer {
plugin.setChatFilter(filter); plugin.setChatFilter(filter);
plugin.getServer().getPluginManager().registerEvents( plugin.getServer().getPluginManager().registerEvents(
new ChatEventListener(plugin, messageService, playerService, filter), plugin new ChatEventListener(plugin, messageService, playerService, filter,
serviceRegistry.get(com.loki.lochat.api.service.ChannelService.class)), plugin
); );
plugin.getServer().getPluginManager().registerEvents( plugin.getServer().getPluginManager().registerEvents(
new PlayerEventListener(plugin, playerService, messagingService, nickService, filter), plugin new PlayerEventListener(plugin, playerService, messagingService, nickService, filter), plugin

View file

@ -1,6 +1,7 @@
package com.loki.lochat.core.registry; package com.loki.lochat.core.registry;
import com.loki.lochat.LoChat; import com.loki.lochat.LoChat;
import com.loki.lochat.api.service.ChannelService;
import com.loki.lochat.api.service.ChatService; import com.loki.lochat.api.service.ChatService;
import com.loki.lochat.api.service.MentionService; import com.loki.lochat.api.service.MentionService;
import com.loki.lochat.api.service.MessageService; import com.loki.lochat.api.service.MessageService;
@ -14,6 +15,7 @@ import com.loki.lochat.config.ConfigManager;
import com.loki.lochat.config.MessageConfig; import com.loki.lochat.config.MessageConfig;
import com.loki.lochat.core.factory.ServiceFactory; import com.loki.lochat.core.factory.ServiceFactory;
import com.loki.lochat.core.service.ChatServiceImpl; import com.loki.lochat.core.service.ChatServiceImpl;
import com.loki.lochat.core.service.ChannelServiceImpl;
import com.loki.lochat.core.service.MentionServiceImpl; import com.loki.lochat.core.service.MentionServiceImpl;
import com.loki.lochat.core.service.MessageServiceImpl; import com.loki.lochat.core.service.MessageServiceImpl;
import com.loki.lochat.core.service.NickServiceImpl; import com.loki.lochat.core.service.NickServiceImpl;
@ -47,6 +49,8 @@ public class ServiceRegistry {
PlayerService playerService = new PlayerServiceImpl(plugin); PlayerService playerService = new PlayerServiceImpl(plugin);
register(PlayerService.class, playerService); register(PlayerService.class, playerService);
register(ChannelService.class, new ChannelServiceImpl(configManager.getChannelConfig()));
MessageService messageService = new MessageServiceImpl(plugin, muteService, playerService); MessageService messageService = new MessageServiceImpl(plugin, muteService, playerService);
register(MessageService.class, messageService); register(MessageService.class, messageService);

View file

@ -0,0 +1,85 @@
package com.loki.lochat.core.service;
import com.loki.lochat.api.service.ChannelService;
import com.loki.lochat.channel.Channel;
import com.loki.lochat.config.ChannelConfig;
import org.bukkit.entity.Player;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Реализация сервиса каналов чата.
* Хранит активный (присоединённый) канал каждого игрока в памяти.
*/
public class ChannelServiceImpl implements ChannelService {
private final ChannelConfig config;
private final Map<UUID, String> activeChannels = new ConcurrentHashMap<>();
public ChannelServiceImpl(ChannelConfig config) {
this.config = config;
}
@Override
public boolean isEnabled() {
return config.isEnabled();
}
@Override
public Channel getDefault() {
return config.getDefault();
}
@Override
public Collection<Channel> getChannels() {
return config.getChannels();
}
@Override
public Channel find(String nameOrAlias) {
return config.find(nameOrAlias);
}
@Override
public Optional<Channel> getActive(Player player) {
String id = activeChannels.get(player.getUniqueId());
if (id == null) {
return Optional.empty();
}
return Optional.ofNullable(config.get(id));
}
@Override
public boolean isInChannel(Player player) {
return activeChannels.containsKey(player.getUniqueId());
}
@Override
public boolean canUse(Player player, Channel channel) {
return !channel.hasPermission() || player.hasPermission(channel.permission());
}
@Override
public boolean join(Player player, Channel channel) {
if (channel == null || !canUse(player, channel)) {
return false;
}
Channel defaultChannel = config.getDefault();
if (defaultChannel != null && channel.id().equals(defaultChannel.id())) {
activeChannels.remove(player.getUniqueId());
return true;
}
activeChannels.put(player.getUniqueId(), channel.id());
return true;
}
@Override
public void leave(Player player) {
activeChannels.remove(player.getUniqueId());
}
}

View file

@ -1,8 +1,10 @@
package com.loki.lochat.listener; package com.loki.lochat.listener;
import com.loki.lochat.LoChat; import com.loki.lochat.LoChat;
import com.loki.lochat.api.service.ChannelService;
import com.loki.lochat.api.service.MessageService; import com.loki.lochat.api.service.MessageService;
import com.loki.lochat.api.service.PlayerService; import com.loki.lochat.api.service.PlayerService;
import com.loki.lochat.channel.Channel;
import com.loki.lochat.core.filter.AdvancedMessageFilter; import com.loki.lochat.core.filter.AdvancedMessageFilter;
import com.loki.lochat.core.filter.FilterResult; import com.loki.lochat.core.filter.FilterResult;
import com.loki.lochat.renderer.EnhancedChatRenderer; import com.loki.lochat.renderer.EnhancedChatRenderer;
@ -24,13 +26,15 @@ public class ChatEventListener implements Listener {
private final MessageService messageService; private final MessageService messageService;
private final PlayerService playerService; private final PlayerService playerService;
private final AdvancedMessageFilter advancedFilter; private final AdvancedMessageFilter advancedFilter;
private final ChannelService channelService;
public ChatEventListener(JavaPlugin plugin, MessageService messageService, PlayerService playerService, public ChatEventListener(JavaPlugin plugin, MessageService messageService, PlayerService playerService,
AdvancedMessageFilter filter) { AdvancedMessageFilter filter, ChannelService channelService) {
this.plugin = plugin; this.plugin = plugin;
this.messageService = messageService; this.messageService = messageService;
this.playerService = playerService; this.playerService = playerService;
this.advancedFilter = filter; this.advancedFilter = filter;
this.channelService = channelService;
} }
@EventHandler(priority = EventPriority.HIGHEST) @EventHandler(priority = EventPriority.HIGHEST)
@ -39,6 +43,13 @@ public class ChatEventListener implements Listener {
String rawMessage = PlainTextComponentSerializer.plainText().serialize(event.message()); String rawMessage = PlainTextComponentSerializer.plainText().serialize(event.message());
LoChat loChat = (LoChat) plugin; LoChat loChat = (LoChat) plugin;
// Система каналов: если игрок в присоединённом канале — маршрутизируем туда
Channel active = channelService.isEnabled() ? channelService.getActive(sender).orElse(null) : null;
if (active != null) {
handleChannelChat(event, sender, active, rawMessage);
return;
}
// Определяем режим чата (глобальный/локальный) // Определяем режим чата (глобальный/локальный)
ChatMode mode = determineChatMode(loChat, rawMessage); ChatMode mode = determineChatMode(loChat, rawMessage);
if (mode == null) { if (mode == null) {
@ -66,6 +77,52 @@ public class ChatEventListener implements Listener {
recordStatistics(loChat, sender, isGlobal); recordStatistics(loChat, sender, isGlobal);
} }
/**
* Маршрутизация сообщения в присоединённый (не-дефолтный) канал.
*/
private void handleChannelChat(AsyncChatEvent event, Player sender, Channel channel, String rawMessage) {
String plainMessage = rawMessage.stripLeading();
// Кулдаун канала
int cooldown = channel.cooldown();
if (cooldown > 0 && !sender.hasPermission("chat.bypass.cooldown")) {
String cooldownType = "channel_" + channel.id();
if (playerService.isOnCooldown(sender.getUniqueId(), cooldownType, cooldown)) {
int remaining = playerService.getRemainingCooldown(sender.getUniqueId(), cooldownType, cooldown);
String msg = ((LoChat) plugin).getConfigManager().getMessagesConfig().getCooldownMessage()
.replace("{remaining}", String.valueOf(remaining));
event.setCancelled(true);
sender.sendMessage(ChatFormatter.parse(msg));
return;
}
playerService.setCooldown(sender.getUniqueId(), cooldownType);
}
if (!processFilters(event, sender, plainMessage, channel.isGlobalScope())) {
return;
}
// Радиус локального канала
if (channel.isLocalScope()) {
int radius = channel.radius();
event.viewers().removeIf(v -> {
if (!(v instanceof Player p)) {
return false;
}
try {
return !com.loki.lochat.utils.player.DistanceUtil.isInRange(sender, p, radius);
} catch (Exception e) {
return false;
}
});
}
event.renderer(new EnhancedChatRenderer(plugin, channel));
LoChat loChat = (LoChat) plugin;
loChat.getDiscordIntegration().sendChatMessage(sender, plainMessage, channel.isGlobalScope());
playerService.recordMessage(sender.getUniqueId(), "channel_" + channel.id());
}
private ChatMode determineChatMode(LoChat loChat, String rawMessage) { private ChatMode determineChatMode(LoChat loChat, String rawMessage) {
boolean localEnabled = loChat.getConfigManager().isLocalEnabled(); boolean localEnabled = loChat.getConfigManager().isLocalEnabled();
boolean isGlobal; boolean isGlobal;

View file

@ -1,9 +1,11 @@
package com.loki.lochat.renderer; package com.loki.lochat.renderer;
import com.loki.lochat.channel.Channel;
import com.loki.lochat.renderer.components.EmojiComponent; import com.loki.lochat.renderer.components.EmojiComponent;
import com.loki.lochat.renderer.components.GradientBuilder; import com.loki.lochat.renderer.components.GradientBuilder;
import com.loki.lochat.renderer.components.UrlProcessor; import com.loki.lochat.renderer.components.UrlProcessor;
import com.loki.lochat.translate.TranslationService; import com.loki.lochat.translate.TranslationService;
import com.loki.lochat.utils.format.ChatFormatter;
import com.loki.lochat.utils.format.TextFormatter; import com.loki.lochat.utils.format.TextFormatter;
import com.loki.lochat.utils.player.MentionHandler; import com.loki.lochat.utils.player.MentionHandler;
@ -33,12 +35,22 @@ public class EnhancedChatRenderer implements ChatRenderer {
private final JavaPlugin plugin; private final JavaPlugin plugin;
private final boolean isGlobal; private final boolean isGlobal;
private final Channel channel;
private final MentionHandler mentionHandler; private final MentionHandler mentionHandler;
private final com.loki.lochat.config.ConfigManager cfg; private final com.loki.lochat.config.ConfigManager cfg;
public EnhancedChatRenderer(JavaPlugin plugin, boolean isGlobal) { public EnhancedChatRenderer(JavaPlugin plugin, boolean isGlobal) {
this(plugin, isGlobal, null);
}
public EnhancedChatRenderer(JavaPlugin plugin, Channel channel) {
this(plugin, channel.isGlobalScope(), channel);
}
private EnhancedChatRenderer(JavaPlugin plugin, boolean isGlobal, Channel channel) {
this.plugin = plugin; this.plugin = plugin;
this.isGlobal = isGlobal; this.isGlobal = isGlobal;
this.channel = channel;
this.mentionHandler = new MentionHandler(plugin); this.mentionHandler = new MentionHandler(plugin);
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin; com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
@ -86,6 +98,26 @@ public class EnhancedChatRenderer implements ChatRenderer {
)); ));
} }
private String emojiFor() {
if (channel != null) {
return channel.display() == null ? "" : channel.display();
}
com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig();
return isGlobal ? appearanceCfg.getGlobalEmoji() : appearanceCfg.getLocalEmoji();
}
private String prefixText() {
if (channel != null) {
return channel.prefix() == null ? channel.id() : channel.prefix();
}
com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig();
return isGlobal ? appearanceCfg.getGlobalText() : appearanceCfg.getLocalText();
}
private String channelColor() {
return channel != null && channel.color() != null ? channel.color() : "&7";
}
private Component buildFormattedMessage(String format, Map<String, Component> parts) { private Component buildFormattedMessage(String format, Map<String, Component> parts) {
Component result = Component.empty(); Component result = Component.empty();
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\{([^}]+)\\}"); java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\{([^}]+)\\}");
@ -112,8 +144,7 @@ public class EnhancedChatRenderer implements ChatRenderer {
} }
private Component buildEmojiComponent() { private Component buildEmojiComponent() {
com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig(); String emoji = emojiFor();
String emoji = isGlobal ? appearanceCfg.getGlobalEmoji() : appearanceCfg.getLocalEmoji();
return emoji.isEmpty() ? Component.empty() : Component.text(emoji); return emoji.isEmpty() ? Component.empty() : Component.text(emoji);
} }
@ -146,9 +177,11 @@ public class EnhancedChatRenderer implements ChatRenderer {
} }
private Component buildPrefix() { private Component buildPrefix() {
String text = prefixText();
if (channel != null) {
return ChatFormatter.parse(channelColor() + text);
}
com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig(); com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig();
String text = isGlobal ? appearanceCfg.getGlobalText() : appearanceCfg.getLocalText();
List<String> colors = isGlobal ? appearanceCfg.getGlobalColors() : appearanceCfg.getLocalColors(); List<String> colors = isGlobal ? appearanceCfg.getGlobalColors() : appearanceCfg.getLocalColors();
return GradientBuilder.build(text, colors); return GradientBuilder.build(text, colors);
@ -157,7 +190,9 @@ public class EnhancedChatRenderer implements ChatRenderer {
private Component buildSeparator() { private Component buildSeparator() {
com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig(); com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig();
String text = isGlobal ? appearanceCfg.getGlobalSeparatorText() : appearanceCfg.getLocalSeparatorText(); String text = isGlobal ? appearanceCfg.getGlobalSeparatorText() : appearanceCfg.getLocalSeparatorText();
String color = isGlobal ? appearanceCfg.getGlobalSeparatorColor() : appearanceCfg.getLocalSeparatorColor(); String color = channel != null
? channelColor()
: (isGlobal ? appearanceCfg.getGlobalSeparatorColor() : appearanceCfg.getLocalSeparatorColor());
return Component.text(text, parseColor(color)); return Component.text(text, parseColor(color));
} }

View file

@ -0,0 +1,57 @@
# LoChat channels
# Channels let players choose where their chat messages go.
# /join <channel> — join a channel
# /leave — leave the current channel (back to default chat)
# /channel [name|list|off] — view info, switch, or list channels
#
# Per channel:
# display — icon shown before the channel name in messages
# prefix — channel label
# color — color of the prefix (legacy & codes or &#RRGGBB)
# radius -1 = global (all players), >0 = local radius in blocks
# cooldown seconds between messages in this channel (0 = off)
# permission required to join/use the channel (empty = everyone)
# aliases alternative names to reference the channel
channels:
# Master switch for the channel system
enabled: true
# Default channel id: when a player is not in a joined channel, they fall back to it
default: "global"
list:
global:
display: "\u2726"
prefix: "GLOBAL"
color: "&b"
radius: -1
cooldown: 3
permission: ""
aliases: [ g ]
local:
display: "\u25C6"
prefix: "LOCAL"
color: "&d"
radius: 100
cooldown: 1
permission: ""
aliases: [ l ]
staff:
display: "\u2694"
prefix: "STAFF"
color: "&6"
radius: -1
cooldown: 2
permission: "lochat.channel.staff"
aliases: [ s, staffchat ]
trade:
display: "\u27A4"
prefix: "TRADE"
color: "&e"
radius: -1
cooldown: 5
permission: "lochat.channel.trade"
aliases: [ t, tradechat ]

View file

@ -20,6 +20,24 @@ commands:
permission: chat.global.use permission: chat.global.use
aliases: [ п ] aliases: [ п ]
join:
description: Join a chat channel
usage: /join <channel>
permission: chat.channel.join
aliases: [ войти ]
leave:
description: Leave the current chat channel
usage: /leave
permission: chat.channel.leave
aliases: [ выйти ]
channel:
description: View or switch chat channel
usage: /channel [channel|list|off]
permission: chat.channel.use
aliases: [ ch, канал, channels, каналы ]
globalchat: globalchat:
description: Toggle global chat description: Toggle global chat
usage: /globalchat toggle usage: /globalchat toggle
@ -225,6 +243,21 @@ commands:
permissions: permissions:
chat.global.use: chat.global.use:
default: true default: true
chat.channel.use:
description: Can use /channel
default: true
chat.channel.join:
description: Can use /join
default: true
chat.channel.leave:
description: Can use /leave
default: true
lochat.channel.staff:
description: Access to the staff channel
default: op
lochat.channel.trade:
description: Access to the trade channel
default: op
chat.global.toggle: chat.global.toggle:
default: true default: true
chat.global.color: chat.global.color:

View file

@ -0,0 +1,111 @@
package com.loki.lochat.core.service;
import com.loki.lochat.api.service.ChannelService;
import com.loki.lochat.channel.Channel;
import com.loki.lochat.config.ChannelConfig;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
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 ChannelServiceImplTest {
private final Channel global = new Channel("global", "\u2726", "GLOBAL", "&b", -1, 3, "", List.of("g"));
private final Channel staff = new Channel("staff", "\u2694", "STAFF", "&6", -1, 2,
"lochat.channel.staff", List.of("s"));
private Player player() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
return player;
}
private ChannelService createService() {
ChannelConfig config = mock(ChannelConfig.class);
when(config.isEnabled()).thenReturn(true);
when(config.getDefault()).thenReturn(global);
when(config.get("global")).thenReturn(global);
when(config.get("staff")).thenReturn(staff);
when(config.find("global")).thenReturn(global);
when(config.find("staff")).thenReturn(staff);
when(config.find("s")).thenReturn(staff);
when(config.getChannels()).thenReturn(List.of(global, staff));
return new ChannelServiceImpl(config);
}
@Test
void getActive_emptyByDefault() {
Player player = player();
ChannelService service = createService();
assertTrue(service.getActive(player).isEmpty());
assertFalse(service.isInChannel(player));
}
@Test
void join_nonDefaultChannel() {
Player player = player();
when(player.hasPermission("lochat.channel.staff")).thenReturn(true);
ChannelService service = createService();
assertTrue(service.join(player, staff));
Optional<Channel> active = service.getActive(player);
assertTrue(active.isPresent());
assertEquals("staff", active.get().id());
assertTrue(service.isInChannel(player));
}
@Test
void join_defaultChannelMeansNotJoined() {
Player player = player();
ChannelService service = createService();
assertTrue(service.join(player, global));
assertTrue(service.getActive(player).isEmpty());
assertFalse(service.isInChannel(player));
}
@Test
void join_withoutPermissionFails() {
Player player = player();
when(player.hasPermission("lochat.channel.staff")).thenReturn(false);
ChannelService service = createService();
assertFalse(service.join(player, staff));
assertFalse(service.isInChannel(player));
}
@Test
void join_withPermission() {
Player player = player();
when(player.hasPermission("lochat.channel.staff")).thenReturn(true);
ChannelService service = createService();
assertTrue(service.join(player, staff));
assertEquals("staff", service.getActive(player).orElseThrow().id());
}
@Test
void leave_clearsActiveChannel() {
Player player = player();
when(player.hasPermission("lochat.channel.staff")).thenReturn(true);
ChannelService service = createService();
service.join(player, staff);
service.leave(player);
assertFalse(service.isInChannel(player));
assertTrue(service.getActive(player).isEmpty());
}
@Test
void find_byAlias() {
ChannelService service = createService();
Channel byId = service.find("staff");
Channel byAlias = service.find("s");
assertEquals("staff", byId.id());
assertEquals("staff", byAlias.id());
}
}