Channels per Name oder ID, Namen werden aufgelöst und als ID in die Config zurückgeschrieben

This commit is contained in:
MineTec committed 2026-10-18 16:04:04 +02:00
1 parent 70e51abaf3
commit 3c0e3ed17a
9 files changed
+208 -40

No files matched your search

+4
View File
@@ -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
+8 -10
View File
@@ -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: ""
@@ -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();
}
@@ -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", "")));
}
}
@@ -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<String> 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<String> 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;
}
}
@@ -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();
}
@@ -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,28 +108,34 @@ 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;
}
List<VoiceChannel> matches = guild.getVoiceChannelsByName(ref, false);
if (matches.size() > 1) {
throw new IllegalStateException("mehrere Voice-Channels heißen \"" + ref + "\", bitte die ID eintragen");
}
VoiceChannel channel;
if (matches.size() == 1) {
channel = matches.getFirst();
} else {
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");
LOG.info("{}: lege Discord-Voice-Channel {} an", room.name(), ref);
channel = category != null ? category.createVoiceChannel(ref).complete() : guild.createVoiceChannel(ref).complete();
}
for (VoiceChannel channel : category != null ? category.getVoiceChannels() : guild.getVoiceChannels()) {
if (channel.getName().equals(room.name()) && (category != null || channel.getParentCategory() == null)) {
updater.setRoomValue(room.key(), "discordChannel", "\"" + channel.getId() + "\"", ref);
return channel;
}
}
LOG.info("{}: lege Discord-Voice-Channel an", room.name());
return category != null
? category.createVoiceChannel(room.name()).complete()
: guild.createVoiceChannel(room.name()).complete();
}
/** Menschen im Raum-Channel (Bots zählen nicht). */
public int humans() {
@@ -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<Room> 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<Room> rooms) {
public TeamspeakSide(BridgeConfig.Teamspeak config, Path dataDir, List<Room> 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<Integer> 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);
}
}
@@ -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<String> 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<String> 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<String> 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));
}
}