Channels per Name oder ID, Namen werden aufgelöst und als ID in die Config zurückgeschrieben
This commit is contained in:
1 parent
70e51abaf3
commit
3c0e3ed17a
9 files changed
+208
-40
No files matched your search
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user