Chat-Einstellungen, Tabliste fürs Netzwerk, Fixes aus dem Review
This commit is contained in:
1 parent
0a07f0bf7c
commit
735dc8867d
13 files changed
+363
-44
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# chat-bridge
|
# chat-bridge
|
||||||
|
|
||||||
Velocity-Plugin für CraftAttack: Der Chat gilt im ganzen Netzwerk (craftattack, arena, Limbo, Event).
|
Velocity-Plugin für CraftAttack: Chat und Tabliste gelten im ganzen Netzwerk (craftattack, arena, Limbo, Event).
|
||||||
Velocity verteilt jede Zeile als Systemnachricht an alle Spieler; die Backends verwerfen den Chat
|
Velocity verteilt jede Zeile als Systemnachricht an alle Spieler; die Backends verwerfen den Chat
|
||||||
selbst („Silencer“), sonst stünde er dort doppelt.
|
selbst („Silencer“), sonst stünde er dort doppelt.
|
||||||
|
|
||||||
@@ -17,6 +17,13 @@ verteilt sie selbst.
|
|||||||
LuckPerms-Permission `chatbridge.muted` in der gemeinsamen MariaDB, läuft von selbst ab. Gestummte
|
LuckPerms-Permission `chatbridge.muted` in der gemeinsamen MariaDB, läuft von selbst ab. Gestummte
|
||||||
dürfen weder öffentlich noch per /msg schreiben, außer an Admins.
|
dürfen weder öffentlich noch per /msg schreiben, außer an Admins.
|
||||||
- **Join/Leave** nur beim Ein- und Ausloggen ins Netzwerk, nicht beim Serverwechsel.
|
- **Join/Leave** nur beim Ein- und Ausloggen ins Netzwerk, nicht beim Serverwechsel.
|
||||||
|
- **Einstellungen** in `/settings` auf spawn (Join/Leave anzeigen, Erwähnungen hervorheben, Erwähnungston):
|
||||||
|
Velocity speichert sie in `plugins/chat-bridge/settings.properties` und gibt sie beim Join an spawn
|
||||||
|
zurück, damit die Arena dieselben Werte zeigt. Unbekannte Spieler übernimmt Velocity nur vom
|
||||||
|
`settings-server` (craftattack), damit die Defaults der Arena keine echten Werte verdrängen.
|
||||||
|
- **Tabliste:** Spieler anderer Server stehen unten mit Server dahinter (`tab-tags`, sonst Chat-Kürzel).
|
||||||
|
Die Einträge vom eigenen Server schickt weiter das Backend (Skins, Spielmodus, Prefixe). Abgleich
|
||||||
|
alle 2 s und kurz nach Join, Wechsel und Quit.
|
||||||
- **Log:** Chat, /msg, Adminchat und Mutes stehen im Velocity-Log (`[chat]`, `[msg]`, `[adminchat]`, `[mute]`).
|
- **Log:** Chat, /msg, Adminchat und Mutes stehen im Velocity-Log (`[chat]`, `[msg]`, `[adminchat]`, `[mute]`).
|
||||||
|
|
||||||
Silencer auf den Backends:
|
Silencer auf den Backends:
|
||||||
@@ -25,7 +32,7 @@ Silencer auf den Backends:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| craftattack, arena | spawn, Appliance `NetworkChat` (verwirft Chat und `/me`, `/teammsg`, `minecraft:msg`) |
|
| craftattack, arena | spawn, Appliance `NetworkChat` (verwirft Chat und `/me`, `/teammsg`, `minecraft:msg`) |
|
||||||
| limbo | EndlessJumper |
|
| limbo | EndlessJumper |
|
||||||
| event | Minigames, `velocity.chat-via-proxy: true` in `resources/config.yml` |
|
| event | Minigames (ab `develop` 8dc1659), `velocity.chat-via-proxy: true` in `resources/config.yml` |
|
||||||
|
|
||||||
Server ohne Silencer stehen in `unsilenced-servers`: Deren Spieler bekommen den Chat vom eigenen
|
Server ohne Silencer stehen in `unsilenced-servers`: Deren Spieler bekommen den Chat vom eigenen
|
||||||
Server nicht noch einmal, dort greift der Mute aber nur für die anderen Server.
|
Server nicht noch einmal, dort greift der Mute aber nur für die anderen Server.
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = 'eu.mhsl.craftattack'
|
group = 'eu.mhsl.craftattack'
|
||||||
version = '1.0.0'
|
version = '1.1.0'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
@@ -39,13 +39,15 @@ final class Chat {
|
|||||||
private final Config config;
|
private final Config config;
|
||||||
private final Names names;
|
private final Names names;
|
||||||
private final Mutes mutes;
|
private final Mutes mutes;
|
||||||
|
private final ChatSettings settings;
|
||||||
|
|
||||||
Chat(ProxyServer proxy, Logger logger, Config config, Names names, Mutes mutes) {
|
Chat(ProxyServer proxy, Logger logger, Config config, Names names, Mutes mutes, ChatSettings settings) {
|
||||||
this.proxy = proxy;
|
this.proxy = proxy;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.names = names;
|
this.names = names;
|
||||||
this.mutes = mutes;
|
this.mutes = mutes;
|
||||||
|
this.settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
void handle(Player sender, String message) {
|
void handle(Player sender, String message) {
|
||||||
@@ -56,34 +58,40 @@ final class Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Set<Player> mentioned = new LinkedHashSet<>();
|
Set<Player> mentioned = new LinkedHashSet<>();
|
||||||
Component line = Component.text()
|
Component highlighted = this.line(server, sender, this.body(message, mentioned));
|
||||||
.append(this.tag(server))
|
Component plain = this.line(server, sender, Component.text(message));
|
||||||
.append(this.names.clickable(sender))
|
|
||||||
.append(Component.text(" > ", GRAY))
|
|
||||||
.append(this.body(message, mentioned).colorIfAbsent(SILVER))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
for (Player player : this.proxy.getAllPlayers()) {
|
for (Player player : this.proxy.getAllPlayers()) {
|
||||||
if (server != null && this.config.unsilencedServers().contains(server) && server.equals(serverOf(player))) continue;
|
if (server != null && this.config.unsilencedServers().contains(server) && server.equals(serverOf(player))) continue;
|
||||||
player.sendMessage(line);
|
ChatSettings.Values values = this.settings.of(player.getUniqueId());
|
||||||
|
player.sendMessage(values.highlightMentions() ? highlighted : plain);
|
||||||
|
if (mentioned.contains(player) && !player.equals(sender) && values.mentionSound()) player.playSound(MENTION);
|
||||||
}
|
}
|
||||||
mentioned.stream()
|
|
||||||
.filter(player -> !player.equals(sender))
|
|
||||||
.forEach(player -> player.playSound(MENTION));
|
|
||||||
|
|
||||||
this.logger.info("[chat] [{}] {}: {}", server, sender.getUsername(), message);
|
this.logger.info("[chat] [{}] {}: {}", server, sender.getUsername(), message);
|
||||||
}
|
}
|
||||||
|
|
||||||
void announceJoin(Player player) {
|
void announceJoin(Player player) {
|
||||||
this.broadcast(Component.text(">>> ", NamedTextColor.GREEN).append(this.names.clickable(player)));
|
this.announce(Component.text(">>> ", NamedTextColor.GREEN).append(this.names.clickable(player)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void announceLeave(Player player) {
|
void announceLeave(Player player) {
|
||||||
this.broadcast(Component.text("<<< ", NamedTextColor.RED).append(this.names.clickable(player)));
|
this.announce(Component.text("<<< ", NamedTextColor.RED).append(this.names.clickable(player)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void broadcast(Component line) {
|
private void announce(Component line) {
|
||||||
this.proxy.getAllPlayers().forEach(player -> player.sendMessage(line));
|
this.proxy.getAllPlayers().stream()
|
||||||
|
.filter(player -> this.settings.of(player.getUniqueId()).joinAndLeave())
|
||||||
|
.forEach(player -> player.sendMessage(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component line(@Nullable String server, Player sender, Component body) {
|
||||||
|
return Component.text()
|
||||||
|
.append(this.tag(server))
|
||||||
|
.append(this.names.clickable(sender))
|
||||||
|
.append(Component.text(" > ", GRAY))
|
||||||
|
.append(body.colorIfAbsent(SILVER))
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Meldet einem gestummten Spieler die Restzeit; true, wenn er gestummt ist. */
|
/** Meldet einem gestummten Spieler die Restzeit; true, wenn er gestummt ist. */
|
||||||
|
|||||||
@@ -13,19 +13,23 @@ import net.luckperms.api.LuckPermsProvider;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@Plugin(
|
@Plugin(
|
||||||
id = "chat-bridge",
|
id = "chat-bridge",
|
||||||
name = "chat-bridge",
|
name = "chat-bridge",
|
||||||
version = "1.0.0",
|
version = "1.1.0",
|
||||||
description = "Serverübergreifender Chat: öffentlicher Chat, /msg, Adminchat, Mute und Join/Leave",
|
description = "Serverübergreifender Chat und Tabliste: öffentlicher Chat, /msg, Adminchat, Mute, Join/Leave",
|
||||||
authors = {"MHSL"},
|
authors = {"MHSL"},
|
||||||
dependencies = {@Dependency(id = "luckperms")}
|
dependencies = {@Dependency(id = "luckperms")}
|
||||||
)
|
)
|
||||||
public final class ChatBridge {
|
public final class ChatBridge {
|
||||||
/** Backend → Proxy: Rang-Prefixe aus spawn ({@code "prefix"}, danach Adventure-JSON oder leer). */
|
/** Kanal zu spawn (Appliance NetworkChat): Rang-Prefixe und Chat-Einstellungen, Format siehe dort. */
|
||||||
static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.create("craftattack", "chat");
|
static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.create("craftattack", "chat");
|
||||||
|
|
||||||
|
/** Abgleich der Tablisten; nach Join, Wechsel und Quit zusätzlich kurz danach. */
|
||||||
|
static final long TAB_REFRESH_SECONDS = 2;
|
||||||
|
|
||||||
private final ProxyServer proxy;
|
private final ProxyServer proxy;
|
||||||
private final Logger logger;
|
private final Logger logger;
|
||||||
private final Path dataDirectory;
|
private final Path dataDirectory;
|
||||||
@@ -47,13 +51,24 @@ public final class ChatBridge {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatSettings settings;
|
||||||
|
try {
|
||||||
|
settings = new ChatSettings(this.dataDirectory, this.logger);
|
||||||
|
} catch (Exception e) {
|
||||||
|
this.logger.error("settings.properties nicht lesbar, chat-bridge ist aus", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Names names = new Names(config);
|
Names names = new Names(config);
|
||||||
Mutes mutes = new Mutes(LuckPermsProvider.get());
|
Mutes mutes = new Mutes(LuckPermsProvider.get());
|
||||||
Chat chat = new Chat(this.proxy, this.logger, config, names, mutes);
|
Chat chat = new Chat(this.proxy, this.logger, config, names, mutes, settings);
|
||||||
PrivateMessages privateMessages = new PrivateMessages(this.proxy, this.logger, config, names, chat);
|
PrivateMessages privateMessages = new PrivateMessages(this.proxy, this.logger, config, names, chat);
|
||||||
|
|
||||||
|
TabLists tabLists = new TabLists(this.proxy, config, names);
|
||||||
|
|
||||||
this.proxy.getChannelRegistrar().register(CHANNEL);
|
this.proxy.getChannelRegistrar().register(CHANNEL);
|
||||||
this.proxy.getEventManager().register(this, new ChatListener(this, this.proxy, this.logger, names, chat, privateMessages));
|
this.proxy.getEventManager().register(this, new ChatListener(this, this.proxy, this.logger, config, names, chat, privateMessages, settings, tabLists));
|
||||||
|
this.proxy.getScheduler().buildTask(this, tabLists::refresh).repeat(TAB_REFRESH_SECONDS, TimeUnit.SECONDS).schedule();
|
||||||
Commands.register(this, this.proxy, this.logger, config, names, chat, privateMessages, mutes);
|
Commands.register(this, this.proxy, this.logger, config, names, chat, privateMessages, mutes);
|
||||||
|
|
||||||
this.logger.info("chat-bridge aktiv, Kürzel {}, ohne Silencer {}", config.serverTags(), config.unsilencedServers());
|
this.logger.info("chat-bridge aktiv, Kürzel {}, ohne Silencer {}", config.serverTags(), config.unsilencedServers());
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package eu.mhsl.craftattack.chatbridge;
|
package eu.mhsl.craftattack.chatbridge;
|
||||||
|
|
||||||
import com.google.common.io.ByteArrayDataInput;
|
import com.google.common.io.ByteArrayDataInput;
|
||||||
|
import com.google.common.io.ByteArrayDataOutput;
|
||||||
|
import com.google.common.io.ByteStreams;
|
||||||
import com.velocitypowered.api.event.Subscribe;
|
import com.velocitypowered.api.event.Subscribe;
|
||||||
import com.velocitypowered.api.event.connection.DisconnectEvent;
|
import com.velocitypowered.api.event.connection.DisconnectEvent;
|
||||||
import com.velocitypowered.api.event.connection.PluginMessageEvent;
|
import com.velocitypowered.api.event.connection.PluginMessageEvent;
|
||||||
@@ -12,9 +14,9 @@ import com.velocitypowered.api.proxy.ServerConnection;
|
|||||||
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
|
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
final class ChatListener {
|
final class ChatListener {
|
||||||
@@ -27,16 +29,23 @@ final class ChatListener {
|
|||||||
private final Names names;
|
private final Names names;
|
||||||
private final Chat chat;
|
private final Chat chat;
|
||||||
private final PrivateMessages privateMessages;
|
private final PrivateMessages privateMessages;
|
||||||
|
private final ChatSettings settings;
|
||||||
|
private final Config config;
|
||||||
|
private final TabLists tabLists;
|
||||||
/** Wer eine Join-Meldung bekommen hat, bekommt beim Verlassen auch eine. */
|
/** Wer eine Join-Meldung bekommen hat, bekommt beim Verlassen auch eine. */
|
||||||
private final Set<UUID> announced = ConcurrentHashMap.newKeySet();
|
private final Set<UUID> announced = new HashSet<>();
|
||||||
|
|
||||||
ChatListener(Object plugin, ProxyServer proxy, Logger logger, Names names, Chat chat, PrivateMessages privateMessages) {
|
ChatListener(Object plugin, ProxyServer proxy, Logger logger, Config config, Names names, Chat chat,
|
||||||
|
PrivateMessages privateMessages, ChatSettings settings, TabLists tabLists) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.proxy = proxy;
|
this.proxy = proxy;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.names = names;
|
this.names = names;
|
||||||
this.chat = chat;
|
this.chat = chat;
|
||||||
this.privateMessages = privateMessages;
|
this.privateMessages = privateMessages;
|
||||||
|
this.settings = settings;
|
||||||
|
this.config = config;
|
||||||
|
this.tabLists = tabLists;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,13 +66,30 @@ final class ChatListener {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
ByteArrayDataInput in = event.dataAsDataStream();
|
ByteArrayDataInput in = event.dataAsDataStream();
|
||||||
String type = in.readUTF();
|
UUID player = connection.getPlayer().getUniqueId();
|
||||||
if (type.equals("prefix")) {
|
String server = connection.getServerInfo().getName();
|
||||||
|
switch (in.readUTF()) {
|
||||||
|
case "prefix" -> {
|
||||||
String json = in.readUTF();
|
String json = in.readUTF();
|
||||||
this.names.setPrefix(
|
this.names.setPrefix(player, server, json.isEmpty() ? null : GsonComponentSerializer.gson().deserialize(json));
|
||||||
connection.getPlayer().getUniqueId(),
|
}
|
||||||
connection.getServerInfo().getName(),
|
case "settings-set" -> {
|
||||||
json.isEmpty() ? null : GsonComponentSerializer.gson().deserialize(json));
|
// Andere spawn-Server haben eigene Spielerdaten mit Defaults; die sollen echte Werte nicht verdrängen
|
||||||
|
ChatSettings.Values sent = readSettings(in);
|
||||||
|
if (this.settings.has(player) || server.equals(this.config.settingsServer())) this.settings.set(player, sent);
|
||||||
|
}
|
||||||
|
case "settings-hello" -> {
|
||||||
|
ChatSettings.Values sent = readSettings(in);
|
||||||
|
if (this.settings.has(player)) {
|
||||||
|
// Velocity kennt die Werte schon, das Backend übernimmt sie
|
||||||
|
ChatSettings.Values known = this.settings.of(player);
|
||||||
|
if (!known.equals(sent)) sendSettings(connection, known);
|
||||||
|
} else if (server.equals(this.config.settingsServer())) {
|
||||||
|
this.settings.set(player, sent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
this.logger.warn("Ungültige Nachricht von {} für {}", connection.getServerInfo().getName(),
|
this.logger.warn("Ungültige Nachricht von {} für {}", connection.getServerInfo().getName(),
|
||||||
@@ -71,12 +97,29 @@ final class ChatListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ChatSettings.Values readSettings(ByteArrayDataInput in) {
|
||||||
|
return new ChatSettings.Values(in.readBoolean(), in.readBoolean(), in.readBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendSettings(ServerConnection connection, ChatSettings.Values values) {
|
||||||
|
ByteArrayDataOutput out = ByteStreams.newDataOutput();
|
||||||
|
out.writeUTF("settings");
|
||||||
|
out.writeBoolean(values.joinAndLeave());
|
||||||
|
out.writeBoolean(values.highlightMentions());
|
||||||
|
out.writeBoolean(values.mentionSound());
|
||||||
|
connection.sendPluginMessage(ChatBridge.CHANNEL, out.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
@Subscribe
|
@Subscribe
|
||||||
public void onServerPostConnect(ServerPostConnectEvent event) {
|
public void onServerPostConnect(ServerPostConnectEvent event) {
|
||||||
if (event.getPreviousServer() != null) return;
|
|
||||||
Player player = event.getPlayer();
|
Player player = event.getPlayer();
|
||||||
|
this.tabLists.reset(player.getUniqueId());
|
||||||
|
this.refreshTabListsSoon();
|
||||||
|
if (event.getPreviousServer() != null) return;
|
||||||
this.proxy.getScheduler().buildTask(this.plugin, () -> {
|
this.proxy.getScheduler().buildTask(this.plugin, () -> {
|
||||||
|
synchronized (this.announced) {
|
||||||
if (!player.isActive() || !this.announced.add(player.getUniqueId())) return;
|
if (!player.isActive() || !this.announced.add(player.getUniqueId())) return;
|
||||||
|
}
|
||||||
this.chat.announceJoin(player);
|
this.chat.announceJoin(player);
|
||||||
}).delay(JOIN_DELAY_MS, TimeUnit.MILLISECONDS).schedule();
|
}).delay(JOIN_DELAY_MS, TimeUnit.MILLISECONDS).schedule();
|
||||||
}
|
}
|
||||||
@@ -84,8 +127,17 @@ final class ChatListener {
|
|||||||
@Subscribe
|
@Subscribe
|
||||||
public void onDisconnect(DisconnectEvent event) {
|
public void onDisconnect(DisconnectEvent event) {
|
||||||
Player player = event.getPlayer();
|
Player player = event.getPlayer();
|
||||||
if (this.announced.remove(player.getUniqueId())) this.chat.announceLeave(player);
|
boolean wasAnnounced;
|
||||||
|
synchronized (this.announced) {
|
||||||
|
wasAnnounced = this.announced.remove(player.getUniqueId());
|
||||||
|
}
|
||||||
|
if (wasAnnounced) this.chat.announceLeave(player);
|
||||||
this.names.forget(player.getUniqueId());
|
this.names.forget(player.getUniqueId());
|
||||||
this.privateMessages.forget(player.getUniqueId());
|
this.privateMessages.forget(player.getUniqueId());
|
||||||
|
this.refreshTabListsSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshTabListsSoon() {
|
||||||
|
this.proxy.getScheduler().buildTask(this.plugin, this.tabLists::refresh).delay(500, TimeUnit.MILLISECONDS).schedule();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package eu.mhsl.craftattack.chatbridge;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat-Einstellungen der Spieler, Quelle der Wahrheit für das ganze Netzwerk. Geändert werden sie in
|
||||||
|
* {@code /settings} auf einem spawn-Server, der sie herschickt; beim Join bekommt spawn die
|
||||||
|
* gespeicherten Werte zurück. Gespeichert in {@code plugins/chat-bridge/settings.properties}.
|
||||||
|
*/
|
||||||
|
final class ChatSettings {
|
||||||
|
record Values(boolean joinAndLeave, boolean highlightMentions, boolean mentionSound) {
|
||||||
|
static final Values DEFAULT = new Values(true, true, true);
|
||||||
|
|
||||||
|
String serialize() {
|
||||||
|
return this.joinAndLeave + "," + this.highlightMentions + "," + this.mentionSound;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Values parse(String value) {
|
||||||
|
String[] parts = value.split(",");
|
||||||
|
if (parts.length != 3) throw new IllegalArgumentException("drei Werte erwartet: " + value);
|
||||||
|
return new Values(Boolean.parseBoolean(parts[0]), Boolean.parseBoolean(parts[1]), Boolean.parseBoolean(parts[2]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Path file;
|
||||||
|
private final Logger logger;
|
||||||
|
private final Map<UUID, Values> values = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
ChatSettings(Path dataDirectory, Logger logger) throws IOException {
|
||||||
|
this.file = dataDirectory.resolve("settings.properties");
|
||||||
|
this.logger = logger;
|
||||||
|
if (Files.notExists(this.file)) return;
|
||||||
|
Properties properties = new Properties();
|
||||||
|
try (Reader reader = Files.newBufferedReader(this.file, StandardCharsets.UTF_8)) {
|
||||||
|
properties.load(reader);
|
||||||
|
}
|
||||||
|
properties.forEach((key, value) -> {
|
||||||
|
try {
|
||||||
|
this.values.put(UUID.fromString((String) key), Values.parse((String) value));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
logger.warn("settings.properties: Eintrag {} ungültig, ignoriert", key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Values of(UUID player) {
|
||||||
|
return this.values.getOrDefault(player, Values.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean has(UUID player) {
|
||||||
|
return this.values.containsKey(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set(UUID player, Values values) {
|
||||||
|
if (values.equals(this.values.get(player))) return;
|
||||||
|
this.values.put(player, values);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void save() {
|
||||||
|
Properties properties = new Properties();
|
||||||
|
this.values.forEach((uuid, values) -> properties.setProperty(uuid.toString(), values.serialize()));
|
||||||
|
Path temporary = this.file.resolveSibling("settings.properties.new");
|
||||||
|
try {
|
||||||
|
Files.createDirectories(this.file.getParent());
|
||||||
|
try (Writer writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8)) {
|
||||||
|
properties.store(writer, "chat-bridge: UUID=Join/Leave,Erwähnungen hervorheben,Erwähnungston");
|
||||||
|
}
|
||||||
|
Files.move(temporary, this.file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} catch (IOException e) {
|
||||||
|
this.logger.error("Chat-Einstellungen konnten nicht gespeichert werden", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
|
|
||||||
/** Befehle auf dem Proxy; sie greifen vor dem Backend und funktionieren damit auf jedem Server. */
|
/** Befehle auf dem Proxy; sie greifen vor dem Backend und funktionieren damit auf jedem Server. */
|
||||||
final class Commands {
|
final class Commands {
|
||||||
@@ -93,7 +94,7 @@ final class Commands {
|
|||||||
.thenCompose(uuid -> mutes.mute(uuid, Duration.ofHours(hours)))
|
.thenCompose(uuid -> mutes.mute(uuid, Duration.ofHours(hours)))
|
||||||
.whenComplete((ignored, error) -> {
|
.whenComplete((ignored, error) -> {
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
invocation.source().sendMessage(Component.text("Der Spieler " + args[0] + " ist unbekannt.", NamedTextColor.RED));
|
reportError(invocation, logger, args[0], error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
invocation.source().sendMessage(Component.text(args[0] + " wurde für " + hours + " Stunden gestummt!", NamedTextColor.GREEN));
|
invocation.source().sendMessage(Component.text(args[0] + " wurde für " + hours + " Stunden gestummt!", NamedTextColor.GREEN));
|
||||||
@@ -125,9 +126,13 @@ final class Commands {
|
|||||||
}
|
}
|
||||||
resolve(proxy, mutes, args[0])
|
resolve(proxy, mutes, args[0])
|
||||||
.thenCompose(mutes::unmute)
|
.thenCompose(mutes::unmute)
|
||||||
.whenComplete((ignored, error) -> {
|
.whenComplete((wasMuted, error) -> {
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
invocation.source().sendMessage(Component.text("Der Spieler " + args[0] + " ist unbekannt.", NamedTextColor.RED));
|
reportError(invocation, logger, args[0], error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!wasMuted) {
|
||||||
|
invocation.source().sendMessage(Component.text(args[0] + " war nicht gestummt.", NamedTextColor.GRAY));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
invocation.source().sendMessage(Component.text(args[0] + " ist nicht mehr gestummt.", NamedTextColor.GREEN));
|
invocation.source().sendMessage(Component.text(args[0] + " ist nicht mehr gestummt.", NamedTextColor.GREEN));
|
||||||
@@ -147,12 +152,28 @@ final class Commands {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void reportError(SimpleCommand.Invocation invocation, Logger logger, String name, Throwable error) {
|
||||||
|
Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error;
|
||||||
|
if (cause instanceof UnknownPlayerException) {
|
||||||
|
invocation.source().sendMessage(Component.text("Der Spieler " + name + " ist unbekannt.", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invocation.source().sendMessage(Component.text("Konnte nicht gespeichert werden, siehe Velocity-Log.", NamedTextColor.RED));
|
||||||
|
logger.error("[mute] Fehler für {}", name, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class UnknownPlayerException extends RuntimeException {
|
||||||
|
UnknownPlayerException(String name) {
|
||||||
|
super("unbekannt: " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Online zuerst über den Proxy, sonst über LuckPerms (Spieler, die schon mal da waren). */
|
/** Online zuerst über den Proxy, sonst über LuckPerms (Spieler, die schon mal da waren). */
|
||||||
private static CompletableFuture<UUID> resolve(ProxyServer proxy, Mutes mutes, String name) {
|
private static CompletableFuture<UUID> resolve(ProxyServer proxy, Mutes mutes, String name) {
|
||||||
return proxy.getPlayer(name)
|
return proxy.getPlayer(name)
|
||||||
.map(player -> CompletableFuture.completedFuture(player.getUniqueId()))
|
.map(player -> CompletableFuture.completedFuture(player.getUniqueId()))
|
||||||
.orElseGet(() -> mutes.lookup(name).thenApply(uuid -> {
|
.orElseGet(() -> mutes.lookup(name).thenApply(uuid -> {
|
||||||
if (uuid == null) throw new IllegalArgumentException("unbekannt: " + name);
|
if (uuid == null) throw new UnknownPlayerException(name);
|
||||||
return uuid;
|
return uuid;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import java.util.stream.Collectors;
|
|||||||
*/
|
*/
|
||||||
record Config(
|
record Config(
|
||||||
Map<String, String> serverTags,
|
Map<String, String> serverTags,
|
||||||
|
Map<String, String> tabTags,
|
||||||
Set<String> unsilencedServers,
|
Set<String> unsilencedServers,
|
||||||
|
String settingsServer,
|
||||||
String adminPermission,
|
String adminPermission,
|
||||||
String markerPermission
|
String markerPermission
|
||||||
) {
|
) {
|
||||||
@@ -44,7 +46,9 @@ record Config(
|
|||||||
static Config fromProperties(Properties p) {
|
static Config fromProperties(Properties p) {
|
||||||
return new Config(
|
return new Config(
|
||||||
pairs(p.getProperty("server-tags", "")),
|
pairs(p.getProperty("server-tags", "")),
|
||||||
|
pairs(p.getProperty("tab-tags", "")),
|
||||||
Set.copyOf(split(p.getProperty("unsilenced-servers", ""))),
|
Set.copyOf(split(p.getProperty("unsilenced-servers", ""))),
|
||||||
|
p.getProperty("settings-server", "craftattack").trim(),
|
||||||
p.getProperty("admin-permission", "admin").trim(),
|
p.getProperty("admin-permission", "admin").trim(),
|
||||||
p.getProperty("marker-permission", "adminmarker").trim()
|
p.getProperty("marker-permission", "adminmarker").trim()
|
||||||
);
|
);
|
||||||
@@ -54,7 +58,7 @@ record Config(
|
|||||||
Map<String, String> pairs = new HashMap<>();
|
Map<String, String> pairs = new HashMap<>();
|
||||||
for (String entry : split(value)) {
|
for (String entry : split(value)) {
|
||||||
int separator = entry.indexOf('=');
|
int separator = entry.indexOf('=');
|
||||||
if (separator <= 0) throw new IllegalArgumentException("server-tags: '" + entry + "' braucht Server=Kürzel");
|
if (separator <= 0) throw new IllegalArgumentException("'" + entry + "' braucht Server=Kürzel");
|
||||||
pairs.put(entry.substring(0, separator).trim(), entry.substring(separator + 1).trim());
|
pairs.put(entry.substring(0, separator).trim(), entry.substring(separator + 1).trim());
|
||||||
}
|
}
|
||||||
return Map.copyOf(pairs);
|
return Map.copyOf(pairs);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import java.util.Objects;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,8 +47,14 @@ final class Mutes {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
CompletableFuture<Void> unmute(UUID player) {
|
/** true, wenn ein (noch laufender) Mute entfernt wurde. */
|
||||||
return this.modify(player, user -> user.data().clear(node -> node.getKey().equals(NODE)));
|
CompletableFuture<Boolean> unmute(UUID player) {
|
||||||
|
AtomicBoolean wasMuted = new AtomicBoolean();
|
||||||
|
return this.modify(player, user -> {
|
||||||
|
wasMuted.set(user.getNodes(NodeType.PERMISSION).stream()
|
||||||
|
.anyMatch(node -> node.getPermission().equals(NODE) && !node.hasExpired()));
|
||||||
|
user.data().clear(node -> node.getKey().equals(NODE));
|
||||||
|
}).thenApply(ignored -> wasMuted.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
CompletableFuture<UUID> lookup(String name) {
|
CompletableFuture<UUID> lookup(String name) {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ final class PrivateMessages {
|
|||||||
void reply(Player sender, String message) {
|
void reply(Player sender, String message) {
|
||||||
List<Conversation> replyList = this.replyMapping.computeIfAbsent(sender.getUniqueId(), uuid -> new ArrayList<>());
|
List<Conversation> replyList = this.replyMapping.computeIfAbsent(sender.getUniqueId(), uuid -> new ArrayList<>());
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
|
// whisper() sperrt die Liste des Empfängers; erst nach diesem Block, sonst Deadlock bei gleichzeitigem /r
|
||||||
|
Player whisperTo = null;
|
||||||
|
|
||||||
synchronized (replyList) {
|
synchronized (replyList) {
|
||||||
replyList.removeIf(conversation -> conversation.lastSet() < now - CONVERSATION_TIMEOUT_MINUTES * 60_000L);
|
replyList.removeIf(conversation -> conversation.lastSet() < now - CONVERSATION_TIMEOUT_MINUTES * 60_000L);
|
||||||
@@ -81,10 +83,15 @@ final class PrivateMessages {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
replyList.clear();
|
replyList.clear();
|
||||||
this.whisper(sender, target, message);
|
whisperTo = target;
|
||||||
return;
|
} else {
|
||||||
|
this.offerChoice(sender, message, replyList, old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (whisperTo != null) this.whisper(sender, whisperTo, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void offerChoice(Player sender, String message, List<Conversation> replyList, List<Conversation> old) {
|
||||||
TextComponent.Builder choice = Component.text()
|
TextComponent.Builder choice = Component.text()
|
||||||
.append(Component.newline())
|
.append(Component.newline())
|
||||||
.append(PREFIX)
|
.append(PREFIX)
|
||||||
@@ -111,7 +118,6 @@ final class PrivateMessages {
|
|||||||
choice.append(Component.newline());
|
choice.append(Component.newline());
|
||||||
sender.sendMessage(choice.build());
|
sender.sendMessage(choice.build());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void message(Player sender, String targetName, String message) {
|
void message(Player sender, String targetName, String message) {
|
||||||
Player target = this.proxy.getPlayer(targetName).orElse(null);
|
Player target = this.proxy.getPlayer(targetName).orElse(null);
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package eu.mhsl.craftattack.chatbridge;
|
||||||
|
|
||||||
|
import com.velocitypowered.api.proxy.Player;
|
||||||
|
import com.velocitypowered.api.proxy.ProxyServer;
|
||||||
|
import com.velocitypowered.api.proxy.player.TabList;
|
||||||
|
import com.velocitypowered.api.proxy.player.TabListEntry;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergänzt die Tabliste jedes Spielers um die Spieler auf anderen Servern (grau, unten, mit Server).
|
||||||
|
* Die Einträge der Spieler auf dem eigenen Server schickt weiter das Backend (Skins, Spielmodus,
|
||||||
|
* Prefixe); die werden hier nie angefasst.
|
||||||
|
* <p>
|
||||||
|
* Abgleich statt Ereignisketten: Beim Serverwechsel räumen Velocity und das alte Backend die Liste
|
||||||
|
* in unbestimmter Reihenfolge auf, {@link #refresh()} läuft deshalb regelmäßig und stellt den
|
||||||
|
* Sollzustand her.
|
||||||
|
*/
|
||||||
|
final class TabLists {
|
||||||
|
/** Vanilla sortiert höhere Werte nach oben; Backends lassen 0 stehen. */
|
||||||
|
private static final int REMOTE_ORDER = -1000;
|
||||||
|
|
||||||
|
private final ProxyServer proxy;
|
||||||
|
private final Config config;
|
||||||
|
private final Names names;
|
||||||
|
/** Je Betrachter die Einträge, die von hier stammen. */
|
||||||
|
private final Map<UUID, Set<UUID>> added = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
TabLists(ProxyServer proxy, Config config, Names names) {
|
||||||
|
this.proxy = proxy;
|
||||||
|
this.config = config;
|
||||||
|
this.names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void refresh() {
|
||||||
|
for (Player viewer : this.proxy.getAllPlayers()) {
|
||||||
|
String viewerServer = Chat.serverOf(viewer);
|
||||||
|
if (viewerServer == null) continue;
|
||||||
|
TabList tabList = viewer.getTabList();
|
||||||
|
Set<UUID> ours = this.added.computeIfAbsent(viewer.getUniqueId(), uuid -> new HashSet<>());
|
||||||
|
|
||||||
|
Set<UUID> wanted = new HashSet<>();
|
||||||
|
for (Player other : this.proxy.getAllPlayers()) {
|
||||||
|
String otherServer = Chat.serverOf(other);
|
||||||
|
if (other.equals(viewer) || otherServer == null || otherServer.equals(viewerServer)) continue;
|
||||||
|
wanted.add(other.getUniqueId());
|
||||||
|
|
||||||
|
Component displayName = this.displayName(other, otherServer);
|
||||||
|
TabListEntry entry = tabList.getEntry(other.getUniqueId()).orElse(null);
|
||||||
|
if (entry == null || !ours.contains(other.getUniqueId())) {
|
||||||
|
// Ein Rest-Eintrag vom Backend (gerade gewechselt) wird ersetzt
|
||||||
|
if (entry != null) tabList.removeEntry(other.getUniqueId());
|
||||||
|
tabList.addEntry(TabListEntry.builder()
|
||||||
|
.tabList(tabList)
|
||||||
|
.profile(other.getGameProfile())
|
||||||
|
.displayName(displayName)
|
||||||
|
.latency((int) Math.min(other.getPing(), Integer.MAX_VALUE))
|
||||||
|
.gameMode(0)
|
||||||
|
.listed(true)
|
||||||
|
.listOrder(REMOTE_ORDER)
|
||||||
|
.build());
|
||||||
|
ours.add(other.getUniqueId());
|
||||||
|
} else {
|
||||||
|
if (!Objects.equals(entry.getDisplayNameComponent().orElse(null), displayName)) entry.setDisplayName(displayName);
|
||||||
|
int latency = (int) Math.min(other.getPing(), Integer.MAX_VALUE);
|
||||||
|
if (entry.getLatency() != latency) entry.setLatency(latency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ours.removeIf(uuid -> {
|
||||||
|
if (wanted.contains(uuid)) return false;
|
||||||
|
// Jetzt auf demselben Server: Der Eintrag gehört dem Backend, nicht entfernen
|
||||||
|
boolean sameServer = this.proxy.getPlayer(uuid)
|
||||||
|
.map(Chat::serverOf)
|
||||||
|
.map(viewerServer::equals)
|
||||||
|
.orElse(false);
|
||||||
|
if (!sameServer) tabList.removeEntry(uuid);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.added.keySet().removeIf(uuid -> this.proxy.getPlayer(uuid).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach einem Serverwechsel hat der Client eine frische Liste, eigene Einträge neu anlegen. */
|
||||||
|
synchronized void reset(UUID viewer) {
|
||||||
|
this.added.remove(viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component displayName(Player player, String server) {
|
||||||
|
String tag = this.config.tabTags().getOrDefault(server, this.config.serverTags().getOrDefault(server, server));
|
||||||
|
return Component.text()
|
||||||
|
.append(this.names.plain(player))
|
||||||
|
.append(Component.text(" · " + tag, NamedTextColor.DARK_GRAY))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,19 @@
|
|||||||
# Server ohne Eintrag (der Hauptserver) bekommen keins.
|
# Server ohne Eintrag (der Hauptserver) bekommen keins.
|
||||||
server-tags=arena=Arena|limbo=Limbo|event=Event|grand-event=Event|worldmuseum=Museum
|
server-tags=arena=Arena|limbo=Limbo|event=Event|grand-event=Event|worldmuseum=Museum
|
||||||
|
|
||||||
|
# Tabliste: Spieler auf anderen Servern stehen unten mit diesem Namen dahinter. Ohne Eintrag gilt das
|
||||||
|
# Chat-Kürzel, sonst der Servername.
|
||||||
|
tab-tags=craftattack=CraftAttack
|
||||||
|
|
||||||
# Server, die ihren Chat nicht selbst unterdrücken (kein spawn-Plugin bzw. kein Silencer).
|
# Server, die ihren Chat nicht selbst unterdrücken (kein spawn-Plugin bzw. kein Silencer).
|
||||||
# Deren Spieler sehen den Chat vom eigenen Server schon lokal und bekommen ihn von Velocity nicht
|
# Deren Spieler sehen den Chat vom eigenen Server schon lokal und bekommen ihn von Velocity nicht
|
||||||
# noch einmal. Achtung: Dort greift der Mute nur für die anderen Server.
|
# noch einmal. Achtung: Dort greift der Mute nur für die anderen Server.
|
||||||
unsilenced-servers=event|grand-event|worldmuseum
|
unsilenced-servers=event|grand-event|worldmuseum
|
||||||
|
|
||||||
|
# Chat-Einstellungen (/settings in spawn) kennt Velocity zuerst von diesem Server; andere spawn-Server
|
||||||
|
# (Arena) haben eigene Spielerdaten und übernehmen dann die Werte von Velocity.
|
||||||
|
settings-server=craftattack
|
||||||
|
|
||||||
# Adminchat, /mute und /unmute (LuckPerms auf Velocity)
|
# Adminchat, /mute und /unmute (LuckPerms auf Velocity)
|
||||||
admin-permission=admin
|
admin-permission=admin
|
||||||
# Wer diese Permission hat, erscheint blau (invis_admin und technical haben sie auf false)
|
# Wer diese Permission hat, erscheint blau (invis_admin und technical haben sie auf false)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class ConfigTest {
|
|||||||
assertEquals("Arena", config.serverTags().get("arena"));
|
assertEquals("Arena", config.serverTags().get("arena"));
|
||||||
assertEquals(Set.of("event", "grand-event", "worldmuseum"), config.unsilencedServers());
|
assertEquals(Set.of("event", "grand-event", "worldmuseum"), config.unsilencedServers());
|
||||||
assertEquals("adminmarker", config.markerPermission());
|
assertEquals("adminmarker", config.markerPermission());
|
||||||
|
assertEquals("CraftAttack", config.tabTags().get("craftattack"));
|
||||||
|
assertEquals("craftattack", config.settingsServer());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user