From 3c0e3ed17abfd795bab89038e8dc2b3ebb17ca27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 18 Oct 2026 16:04:04 +0200 Subject: [PATCH] =?UTF-8?q?Channels=20per=20Name=20oder=20ID,=20Namen=20we?= =?UTF-8?q?rden=20aufgel=C3=B6st=20und=20als=20ID=20in=20die=20Config=20zu?= =?UTF-8?q?r=C3=BCckgeschrieben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + bridge/config.example.yml | 18 ++-- .../eu/mhsl/voicebridge/bridge/Bridge.java | 6 +- .../mhsl/voicebridge/bridge/BridgeConfig.java | 8 +- .../voicebridge/bridge/ConfigUpdater.java | 89 +++++++++++++++++++ .../java/eu/mhsl/voicebridge/bridge/Main.java | 2 +- .../bridge/discord/DiscordRoomBot.java | 40 +++++---- .../bridge/teamspeak/TeamspeakSide.java | 37 +++++--- .../voicebridge/bridge/ConfigUpdaterTest.java | 46 ++++++++++ 9 files changed, 209 insertions(+), 41 deletions(-) create mode 100644 bridge/src/main/java/eu/mhsl/voicebridge/bridge/ConfigUpdater.java create mode 100644 bridge/src/test/java/eu/mhsl/voicebridge/bridge/ConfigUpdaterTest.java diff --git a/README.md b/README.md index df85eed..fa47e7f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ im Spiel kommt jeder externe Sprecher einzeln an (Lautstärke-Kategorie „Disco Vorlage: `bridge/config.example.yml`. Die echte `config.yml` enthält Bot-Tokens und gehört nicht ins Repo. +`teamspeakChannel` und `discordChannel` pro Raum dürfen ein Name oder eine ID sein. Einen Namen löst die +Bridge beim Start auf (oder legt den Channel an) und schreibt die ID in die `config.yml` zurück, nur diese +Zeile, mit dem alten Namen als Kommentar. Die Datei muss dafür für den Dienst beschreibbar sein. + ## Stolpersteine - **glibc ≥ 2.38**: Die `libdave.so` aus JDave (und Discords eigene Builds) brauchen glibc 2.38 und diff --git a/bridge/config.example.yml b/bridge/config.example.yml index 0545892..82cae24 100644 --- a/bridge/config.example.yml +++ b/bridge/config.example.yml @@ -17,10 +17,8 @@ teamspeak: host: ts.example.org port: 9987 password: "" - # Unter diesem Channel legt die Bridge fehlende Raum-Channels an: - # Channel-ID, Name eines Channels auf oberster Ebene oder 0 für die oberste Ebene selbst. - # Besser die ID: Wird der Channel umbenannt, findet die Bridge ihn über den Namen nicht mehr. - parentChannel: 264 + # Nur zum Anlegen fehlender Raum-Channels: darunter landen sie (Channel-ID oder Name, 0 = oberste Ebene). + parentChannel: 0 # ServerQuery über SSH: sieht alle Clients und Channels, belegt keinen Slot. # Den Login legt man in TeamSpeak unter Extras → ServerQuery-Login an (für eine Identität in der Gruppe "Bridge"). # Der Query-Flood-Schutz sperrt die IP schnell; am besten die IP der Bridge in query_ip_allowlist.txt eintragen. @@ -39,7 +37,7 @@ teamspeak: discord: guild: "000000000000000000" - # In dieser Kategorie legt die Bridge fehlende Raum-Channels an (leer = ohne Kategorie). + # Nur zum Anlegen fehlender Raum-Channels: in dieser Kategorie (leer = ohne Kategorie). category: "000000000000000000" # Server-Nickname der Bots; %room% wird durch den Raumnamen ersetzt. nickname: "%room% (MC/TS)" @@ -49,11 +47,11 @@ rooms: name: Crosstalk 1 # Ein eigener Bot pro Raum (Discord erlaubt pro Bot nur einen Voice-Channel). Leer = ohne Discord. discordToken: "" - # Feste Channels per ID: dann dürfen sie beliebig umbenannt und verschoben werden, und - # teamspeak.parentChannel / discord.category werden nicht gebraucht. Ohne ID sucht die Bridge - # den Channel über den Raumnamen unter parentChannel bzw. in category und legt ihn sonst an. - # discordChannel: "000000000000000000" - # teamspeakChannel: 0 + # Channel als Name oder ID (leer = Raumname). Einen Namen sucht die Bridge auf dem ganzen Server, + # legt den Channel sonst an (unter teamspeak.parentChannel bzw. in discord.category) und schreibt + # danach die ID hier hinein. Ab dann dürfen die Channels frei umbenannt und verschoben werden. + teamspeakChannel: "Crosstalk 1" + discordChannel: "Crosstalk 1" - key: crosstalk-2 name: Crosstalk 2 discordToken: "" diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java index dd30a56..ebd503f 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java @@ -44,18 +44,18 @@ public final class Bridge { boolean teamspeakConnected, discordConnected; } - public Bridge(BridgeConfig config) throws IOException { + public Bridge(BridgeConfig config, ConfigUpdater updater) throws IOException { this.config = config; for (BridgeConfig.RoomConfig roomConfig : config.rooms()) { Room room = new Room(roomConfig); rooms.put(room.key(), room); presence.put(room.key(), new Presence()); - if (roomConfig.discordEnabled()) discordBots.put(room.key(), new DiscordRoomBot(config.discord(), room)); + if (roomConfig.discordEnabled()) discordBots.put(room.key(), new DiscordRoomBot(config.discord(), room, updater)); } this.plugin = new PluginLink(config.pluginBind(), config.pluginPort(), rooms, () -> roomInfos); for (Room room : rooms.values()) room.setMinecraftSink(plugin::sendAudio); this.teamspeak = config.teamspeak().enabled() - ? new TeamspeakSide(config.teamspeak(), config.dataDir(), List.copyOf(rooms.values())) + ? new TeamspeakSide(config.teamspeak(), config.dataDir(), List.copyOf(rooms.values()), updater) : null; roomInfos = buildInfos(); } diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/BridgeConfig.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/BridgeConfig.java index e513c98..d9e4613 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/BridgeConfig.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/BridgeConfig.java @@ -31,8 +31,12 @@ public record BridgeConfig( public record Discord(String guild, String category, String nickname) {} + /** + * {@code discordChannel} und {@code teamspeakChannel} sind eine Channel-ID oder ein Channel-Name + * (leer = Raumname). Namen löst die Bridge auf und schreibt die ID zurück in die Config. + */ public record RoomConfig(String key, String name, String discordToken, String discordChannel, - int teamspeakChannel) { + String teamspeakChannel) { public boolean discordEnabled() { return !discordToken.isBlank(); } } @@ -63,7 +67,7 @@ public record BridgeConfig( if (key.isBlank()) throw new IOException("Raum ohne key in " + file); if (!keys.add(key)) throw new IOException("Raum-key doppelt: " + key); rooms.add(new RoomConfig(key, string(room, "name", key), string(room, "discordToken", ""), - string(room, "discordChannel", ""), integer(room, "teamspeakChannel", 0))); + string(room, "discordChannel", ""), string(room, "teamspeakChannel", ""))); } } diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/ConfigUpdater.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/ConfigUpdater.java new file mode 100644 index 0000000..c2b7cff --- /dev/null +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/ConfigUpdater.java @@ -0,0 +1,89 @@ +package eu.mhsl.voicebridge.bridge; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Schreibt aufgelöste Channel-IDs in die config.yml zurück. Geändert (oder ergänzt) wird nur die eine + * Zeile im Block des Raums, damit Kommentare und Formatierung erhalten bleiben. + */ +public final class ConfigUpdater { + private static final Logger LOG = LoggerFactory.getLogger(ConfigUpdater.class); + private static final Pattern ROOM_START = Pattern.compile("^(\\s*)-\\s+key:\\s*\"?([^\"#\\s]+)\"?.*$"); + + private final Path file; + + public ConfigUpdater(Path file) { + this.file = file; + } + + /** Setzt {@code field} im Raum {@code roomKey} auf {@code value}; {@code was} landet als Kommentar dahinter. */ + public synchronized void setRoomValue(String roomKey, String field, String value, String was) { + try { + List lines = new ArrayList<>(Files.readAllLines(file)); + if (!apply(lines, roomKey, field, value, was)) { + LOG.warn("Raum {} nicht in {} gefunden, {} bitte von Hand eintragen: {}", roomKey, file, field, value); + return; + } + Path tmp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.write(tmp, lines); + // Rechte übernehmen, die Datei enthält Tokens + try { + Files.setPosixFilePermissions(tmp, Files.getPosixFilePermissions(file)); + } catch (UnsupportedOperationException ignored) { + // kein POSIX-Dateisystem + } + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + LOG.info("{}: {} = {} in {} eingetragen", roomKey, field, value, file.getFileName()); + } catch (IOException e) { + LOG.warn("Konnte {} nicht aktualisieren ({}), {} bitte von Hand eintragen: {}", file, e.getMessage(), field, value); + } + } + + static boolean apply(List lines, String roomKey, String field, String value, String was) { + int start = -1; + String itemIndent = null; + for (int i = 0; i < lines.size(); i++) { + Matcher m = ROOM_START.matcher(lines.get(i)); + if (m.matches() && m.group(2).equals(roomKey)) { + start = i; + itemIndent = m.group(1); + break; + } + } + if (start < 0) return false; + + int end = lines.size(); + for (int i = start + 1; i < lines.size(); i++) { + String line = lines.get(i); + if (line.isBlank() || line.stripLeading().startsWith("#")) continue; + int indent = line.length() - line.stripLeading().length(); + if (indent <= itemIndent.length()) { + end = i; + break; + } + } + + String fieldIndent = itemIndent + " "; + String comment = was == null || was.isBlank() ? "" : " # war: " + was; + String newLine = fieldIndent + field + ": " + value + comment; + Pattern fieldPattern = Pattern.compile("^\\s*" + Pattern.quote(field) + ":.*$"); + for (int i = start + 1; i < end; i++) { + if (fieldPattern.matcher(lines.get(i)).matches()) { + lines.set(i, newLine); + return true; + } + } + lines.add(start + 1, newLine); + return true; + } +} diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Main.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Main.java index e4a2413..fe70735 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Main.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Main.java @@ -22,7 +22,7 @@ public final class Main { // -Dvoicebridge.ts3debug=true schaltet das Protokoll-Log von ts3j ein if (Boolean.getBoolean("voicebridge.ts3debug")) Ts3Debugging.setEnabled(true); - Bridge bridge = new Bridge(BridgeConfig.load(configFile)); + Bridge bridge = new Bridge(BridgeConfig.load(configFile), new ConfigUpdater(configFile)); Runtime.getRuntime().addShutdownHook(new Thread(bridge::stop, "shutdown")); bridge.start(); } diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/discord/DiscordRoomBot.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/discord/DiscordRoomBot.java index 625e31d..ba19ef1 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/discord/DiscordRoomBot.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/discord/DiscordRoomBot.java @@ -2,6 +2,7 @@ package eu.mhsl.voicebridge.bridge.discord; import club.minnced.discord.jdave.interop.JDaveSessionFactory; import eu.mhsl.voicebridge.bridge.BridgeConfig; +import eu.mhsl.voicebridge.bridge.ConfigUpdater; import eu.mhsl.voicebridge.bridge.Room; import eu.mhsl.voicebridge.bridge.audio.Opus; import io.github.jaredmdobson.concentus.OpusEncoder; @@ -27,6 +28,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -39,6 +41,7 @@ public final class DiscordRoomBot implements AudioSendHandler, AudioReceiveHandl private static final Logger LOG = LoggerFactory.getLogger(DiscordRoomBot.class); private final BridgeConfig.Discord config; + private final ConfigUpdater updater; private final Room room; private final OpusEncoder encoder = Opus.encoder(2, 64000); private final ExecutorService executor; @@ -48,8 +51,9 @@ public final class DiscordRoomBot implements AudioSendHandler, AudioReceiveHandl private volatile long channelId; private volatile boolean joined; - public DiscordRoomBot(BridgeConfig.Discord config, Room room) { + public DiscordRoomBot(BridgeConfig.Discord config, Room room, ConfigUpdater updater) { this.config = config; + this.updater = updater; this.room = room; this.executor = Executors.newSingleThreadExecutor( Thread.ofPlatform().daemon().name("discord-" + room.key()).factory()); @@ -104,27 +108,33 @@ public final class DiscordRoomBot implements AudioSendHandler, AudioReceiveHandl } } + /** + * Raum-Channel auflösen: eine ID (nur Ziffern) wird direkt genommen, ein Name (leer = Raumname) auf dem + * ganzen Server gesucht und sonst in {@code category} angelegt. Die ID landet danach in der Config. + */ private VoiceChannel resolveChannel(Guild guild) { String configured = room.config().discordChannel(); - if (!configured.isBlank()) { - VoiceChannel channel = guild.getVoiceChannelById(configured); - if (channel == null) throw new IllegalStateException("Voice-Channel " + configured + " gibt es nicht"); + String ref = configured.isBlank() ? room.name() : configured; + if (ref.chars().allMatch(Character::isDigit)) { + VoiceChannel channel = guild.getVoiceChannelById(ref); + if (channel == null) throw new IllegalStateException("Voice-Channel " + ref + " gibt es nicht"); return channel; } - Category category = config.category().isBlank() ? null : guild.getCategoryById(config.category()); - if (!config.category().isBlank() && category == null) { - throw new IllegalStateException("Kategorie " + config.category() + " gibt es nicht"); + List matches = guild.getVoiceChannelsByName(ref, false); + if (matches.size() > 1) { + throw new IllegalStateException("mehrere Voice-Channels heißen \"" + ref + "\", bitte die ID eintragen"); } - for (VoiceChannel channel : category != null ? category.getVoiceChannels() : guild.getVoiceChannels()) { - if (channel.getName().equals(room.name()) && (category != null || channel.getParentCategory() == null)) { - return channel; - } + VoiceChannel channel; + if (matches.size() == 1) { + channel = matches.getFirst(); + } else { + Category category = config.category().isBlank() ? null : guild.getCategoryById(config.category()); + LOG.info("{}: lege Discord-Voice-Channel {} an", room.name(), ref); + channel = category != null ? category.createVoiceChannel(ref).complete() : guild.createVoiceChannel(ref).complete(); } - LOG.info("{}: lege Discord-Voice-Channel an", room.name()); - return category != null - ? category.createVoiceChannel(room.name()).complete() - : guild.createVoiceChannel(room.name()).complete(); + updater.setRoomValue(room.key(), "discordChannel", "\"" + channel.getId() + "\"", ref); + return channel; } /** Menschen im Raum-Channel (Bots zählen nicht). */ diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakSide.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakSide.java index 0c0025c..accda3c 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakSide.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakSide.java @@ -2,6 +2,7 @@ package eu.mhsl.voicebridge.bridge.teamspeak; import com.github.manevolent.ts3j.identity.LocalIdentity; import eu.mhsl.voicebridge.bridge.BridgeConfig; +import eu.mhsl.voicebridge.bridge.ConfigUpdater; import eu.mhsl.voicebridge.bridge.Room; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,6 +38,7 @@ public final class TeamspeakSide { private record TsChannel(int parent, String name) {} private final BridgeConfig.Teamspeak config; + private final ConfigUpdater updater; private final Path dataDir; private final List rooms; private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor( @@ -55,8 +57,9 @@ public final class TeamspeakSide { /** Channel, unter dem die Raum-Channels liegen (0 = oberste Ebene). */ private volatile int parentChannel; - public TeamspeakSide(BridgeConfig.Teamspeak config, Path dataDir, List rooms) { + public TeamspeakSide(BridgeConfig.Teamspeak config, Path dataDir, List rooms, ConfigUpdater updater) { this.config = config; + this.updater = updater; this.dataDir = dataDir; this.rooms = rooms; } @@ -214,28 +217,42 @@ public final class TeamspeakSide { return found; } + /** + * Raum-Channels auflösen: eine ID wird direkt genommen, ein Name (leer = Raumname) auf dem ganzen + * Server gesucht und sonst unter {@code parentChannel} angelegt. Die ID landet danach in der Config. + */ private void resolveChannels(TsQuery q) { for (Room room : rooms) { if (roomChannels.containsKey(room.key())) continue; - int configured = room.config().teamspeakChannel(); - if (configured > 0) { - roomChannels.put(room.key(), configured); + String ref = room.config().teamspeakChannel().isBlank() ? room.name() : room.config().teamspeakChannel(); + int id = parse(ref, -1); + if (id > 0) { + roomChannels.put(room.key(), id); continue; } - Integer found = findChannel(parentChannel, room.name()); - if (found == null) { + + List matches = channels.entrySet().stream() + .filter(e -> e.getValue().name().equals(ref)).map(Map.Entry::getKey).toList(); + if (matches.size() > 1) { + LOG.error("{}: mehrere TeamSpeak-Channels heißen \"{}\" ({}), bitte die ID eintragen", room.name(), ref, matches); + continue; + } + if (matches.size() == 1) { + id = matches.getFirst(); + } else { try { - found = parse(q.send("channelcreate", "channel_name", room.name(), "cpid", Integer.toString(parentChannel), + id = parse(q.send("channelcreate", "channel_name", ref, "cpid", Integer.toString(parentChannel), "channel_flag_permanent", "1", "channel_codec", "4").getFirst().get("cid"), -1); - channels.put(found, new TsChannel(parentChannel, room.name())); - LOG.info("TeamSpeak-Channel angelegt: {} ({})", room.name(), found); + channels.put(id, new TsChannel(parentChannel, ref)); + LOG.info("TeamSpeak-Channel angelegt: {} ({})", ref, id); } catch (IOException e) { LOG.error("{}: TeamSpeak-Channel konnte nicht angelegt werden ({}). Fehlen dem Query-Login die " + "Rechte zum Anlegen? Bis dahin ohne TeamSpeak.", room.name(), e.getMessage()); continue; } } - roomChannels.put(room.key(), found); + roomChannels.put(room.key(), id); + updater.setRoomValue(room.key(), "teamspeakChannel", Integer.toString(id), ref); } } diff --git a/bridge/src/test/java/eu/mhsl/voicebridge/bridge/ConfigUpdaterTest.java b/bridge/src/test/java/eu/mhsl/voicebridge/bridge/ConfigUpdaterTest.java new file mode 100644 index 0000000..bbe3f52 --- /dev/null +++ b/bridge/src/test/java/eu/mhsl/voicebridge/bridge/ConfigUpdaterTest.java @@ -0,0 +1,46 @@ +package eu.mhsl.voicebridge.bridge; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ConfigUpdaterTest { + + private static List config() { + return new ArrayList<>(List.of( + "rooms:", + " - key: crosstalk-1", + " name: Crosstalk 1", + " teamspeakChannel: \"Crosstalk 1\" # Name reicht", + " discordToken: \"x\"", + "", + " # Kommentar", + " - key: crosstalk-2", + " name: Crosstalk 2", + " discordToken: \"y\"")); + } + + @Test + void replacesExistingField() { + List lines = config(); + assertTrue(ConfigUpdater.apply(lines, "crosstalk-1", "teamspeakChannel", "265", "Crosstalk 1")); + assertEquals(" teamspeakChannel: 265 # war: Crosstalk 1", lines.get(3)); + assertEquals(10, lines.size()); + } + + @Test + void addsMissingFieldOnlyInItsRoom() { + List lines = config(); + assertTrue(ConfigUpdater.apply(lines, "crosstalk-2", "discordChannel", "\"123\"", null)); + assertEquals(" discordChannel: \"123\"", lines.get(8)); + assertEquals(" teamspeakChannel: \"Crosstalk 1\" # Name reicht", lines.get(3)); + } + + @Test + void unknownRoom() { + assertFalse(ConfigUpdater.apply(config(), "crosstalk-9", "teamspeakChannel", "1", null)); + } +}