TeamSpeak: ServerQuery über SSH statt Beobachter-Client (kein dauerhafter Slot)

This commit is contained in:
MineTec committed 2026-10-18 15:38:38 +02:00
1 parent 0574fd7f6d
commit 5bf3b845e4
6 files changed
+363 -191

No files matched your search

+2
View File
@@ -11,6 +11,8 @@ dependencies {
implementation 'club.minnced:jdave-native-linux-x86-64:0.1.8' implementation 'club.minnced:jdave-native-linux-x86-64:0.1.8'
// Opus in reinem Java fürs Mischen // Opus in reinem Java fürs Mischen
implementation 'io.github.jaredmdobson:concentus:1.0.2' implementation 'io.github.jaredmdobson:concentus:1.0.2'
// ServerQuery über SSH
implementation 'com.github.mwiede:jsch:2.28.7'
implementation 'org.yaml:snakeyaml:2.7' implementation 'org.yaml:snakeyaml:2.7'
implementation 'org.slf4j:slf4j-simple:2.0.17' implementation 'org.slf4j:slf4j-simple:2.0.17'
+6 -2
View File
@@ -20,8 +20,12 @@ teamspeak:
# Unter diesem Channel legt die Bridge fehlende Raum-Channels an: # 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. # Channel-ID, Name eines Channels auf oberster Ebene oder 0 für die oberste Ebene selbst.
parentChannel: "Crosstalk" parentChannel: "Crosstalk"
# Name des Beobachter-Clients, der die Channels im Blick behält. # ServerQuery über SSH: sieht alle Clients und Channels, belegt keinen Slot.
nickname: "Crosstalk" # 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.
queryPort: 10022
queryLogin: "crosstalk-bridge"
queryPassword: ""
# Name der Raum-Clients; %room% wird durch den Raumnamen ersetzt (TeamSpeak erlaubt höchstens 30 Zeichen). # Name der Raum-Clients; %room% wird durch den Raumnamen ersetzt (TeamSpeak erlaubt höchstens 30 Zeichen).
roomNickname: "%room% (MC/DC)" roomNickname: "%room% (MC/DC)"
# Der TS6-Server auf mhsl.eu verlangt Level 15 (Fehler 519 "could not validate client identity"). # Der TS6-Server auf mhsl.eu verlangt Level 15 (Fehler 519 "could not validate client identity").
@@ -21,8 +21,9 @@ public record BridgeConfig(
Discord discord, Discord discord,
List<RoomConfig> rooms List<RoomConfig> rooms
) { ) {
public record Teamspeak(String host, int port, String password, String parentChannel, String nickname, public record Teamspeak(String host, int port, String password, int queryPort, String queryLogin,
String roomNickname, int securityLevel, ClientVersion clientVersion) { String queryPassword, String parentChannel, String roomNickname, int securityLevel,
ClientVersion clientVersion) {
public boolean enabled() { return !host.isBlank(); } public boolean enabled() { return !host.isBlank(); }
} }
@@ -72,8 +73,9 @@ public record BridgeConfig(
Path.of(string(root, "dataDir", "data")), Path.of(string(root, "dataDir", "data")),
integer(root, "lingerSeconds", 15) * 1000L, integer(root, "lingerSeconds", 15) * 1000L,
new Teamspeak(string(ts, "host", ""), integer(ts, "port", 9987), string(ts, "password", ""), new Teamspeak(string(ts, "host", ""), integer(ts, "port", 9987), string(ts, "password", ""),
string(ts, "parentChannel", "0"), string(ts, "nickname", "Crosstalk"), integer(ts, "queryPort", 10022), string(ts, "queryLogin", ""), string(ts, "queryPassword", ""),
string(ts, "roomNickname", "%room% (MC/DC)"), integer(ts, "securityLevel", 15), version), string(ts, "parentChannel", "0"), string(ts, "roomNickname", "%room% (MC/DC)"),
integer(ts, "securityLevel", 15), version),
new Discord(string(dc, "guild", ""), string(dc, "category", ""), new Discord(string(dc, "guild", ""), string(dc, "category", ""),
string(dc, "nickname", "%room% (MC/TS)")), string(dc, "nickname", "%room% (MC/TS)")),
List.copyOf(rooms)); List.copyOf(rooms));
@@ -1,27 +1,12 @@
package eu.mhsl.voicebridge.bridge.teamspeak; package eu.mhsl.voicebridge.bridge.teamspeak;
import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.ChannelCreateEvent;
import com.github.manevolent.ts3j.event.ChannelDeletedEvent;
import com.github.manevolent.ts3j.event.ChannelEditedEvent;
import com.github.manevolent.ts3j.event.ChannelListEvent;
import com.github.manevolent.ts3j.event.ChannelMovedEvent;
import com.github.manevolent.ts3j.event.ClientJoinEvent;
import com.github.manevolent.ts3j.event.ClientLeaveEvent;
import com.github.manevolent.ts3j.event.ClientMovedEvent;
import com.github.manevolent.ts3j.event.DisconnectedEvent;
import com.github.manevolent.ts3j.event.TS3Listener;
import com.github.manevolent.ts3j.identity.LocalIdentity; import com.github.manevolent.ts3j.identity.LocalIdentity;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
import eu.mhsl.voicebridge.bridge.BridgeConfig; import eu.mhsl.voicebridge.bridge.BridgeConfig;
import eu.mhsl.voicebridge.bridge.Room; import eu.mhsl.voicebridge.bridge.Room;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
@@ -35,13 +20,14 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
* TeamSpeak-Anbindung. Ein Beobachter-Client bleibt dauerhaft verbunden, sieht so alle Clients und * TeamSpeak-Anbindung. Eine ServerQuery (belegt keinen Slot) sieht alle Clients und Channels und legt
* Channels und legt fehlende Raum-Channels an. Pro aktivem Raum verbindet sich zusätzlich ein * fehlende Raum-Channels an. Pro aktivem Raum verbindet sich ein {@link TeamspeakRoomClient}, der im
* {@link TeamspeakRoomClient}, der im Raum-Channel spricht und zuhört. * Raum-Channel spricht und zuhört; nur diese Clients belegen Slots.
*/ */
public final class TeamspeakSide implements TS3Listener { public final class TeamspeakSide {
private static final Logger LOG = LoggerFactory.getLogger(TeamspeakSide.class); private static final Logger LOG = LoggerFactory.getLogger(TeamspeakSide.class);
private static final long RECONNECT_SECONDS = 30; private static final long RECONNECT_SECONDS = 60;
private static final long RESYNC_SECONDS = 60;
/** client_type 0 = normaler Client, 1 = ServerQuery. */ /** client_type 0 = normaler Client, 1 = ServerQuery. */
private static final int CLIENT_TYPE_VOICE = 0; private static final int CLIENT_TYPE_VOICE = 0;
private static final long RETRY_MIN_MILLIS = 30_000; private static final long RETRY_MIN_MILLIS = 30_000;
@@ -64,7 +50,7 @@ public final class TeamspeakSide implements TS3Listener {
private final Map<String, Long> backoff = new ConcurrentHashMap<>(); private final Map<String, Long> backoff = new ConcurrentHashMap<>();
private final Map<String, Long> nextAttempt = new ConcurrentHashMap<>(); private final Map<String, Long> nextAttempt = new ConcurrentHashMap<>();
private volatile LocalTeamspeakClientSocket observer; private volatile TsQuery query;
private volatile boolean ready; private volatile boolean ready;
/** Channel, unter dem die Raum-Channels liegen (0 = oberste Ebene). */ /** Channel, unter dem die Raum-Channels liegen (0 = oberste Ebene). */
private volatile int parentChannel; private volatile int parentChannel;
@@ -76,22 +62,8 @@ public final class TeamspeakSide implements TS3Listener {
} }
public void start() { public void start() {
executor.execute(this::connectObserver); executor.execute(this::connectQuery);
executor.scheduleWithFixedDelay(this::retryMissingChannels, RECONNECT_SECONDS, RECONNECT_SECONDS, TimeUnit.SECONDS); executor.scheduleWithFixedDelay(this::resync, RESYNC_SECONDS, RESYNC_SECONDS, TimeUnit.SECONDS);
}
/** Fehlende Raum-Channels erneut anlegen, z. B. nachdem jemand dem Beobachter die Rechte gegeben hat. */
private void retryMissingChannels() {
LocalTeamspeakClientSocket socket = observer;
if (!ready || socket == null || rooms.stream().allMatch(room -> roomChannels.containsKey(room.key()))) return;
try {
resolveChannels(socket);
joinParent(socket);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
LOG.warn("Fehlende TeamSpeak-Channels: {}", e.toString());
}
} }
public boolean ready() { public boolean ready() {
@@ -157,88 +129,78 @@ public final class TeamspeakSide implements TS3Listener {
private Set<Integer> ownClientIds() { private Set<Integer> ownClientIds() {
Set<Integer> own = new HashSet<>(); Set<Integer> own = new HashSet<>();
LocalTeamspeakClientSocket socket = observer;
if (socket != null) own.add(socket.getClientId());
for (TeamspeakRoomClient client : roomClients.values()) own.add(client.clientId()); for (TeamspeakRoomClient client : roomClients.values()) own.add(client.clientId());
return own; return own;
} }
private void connectObserver() { // --- ServerQuery ---
private void connectQuery() {
try { try {
LocalTeamspeakClientSocket socket = TeamspeakRoomClient.newSocket(config, loadIdentity("ts-observer.ini"), TsQuery q = new TsQuery(config.host(), config.queryPort(), config.queryLogin(), config.queryPassword(),
config.nickname()); this::onNotification, () -> executor.execute(this::onQueryClosed));
socket.addListener(this); query = q;
observer = socket; q.send("use", "port", Integer.toString(config.port()));
socket.connect(new InetSocketAddress(config.host(), config.port()), emptyToNull(config.password()), 15000L); q.send("servernotifyregister", "event", "server");
// Clients in nicht abonnierten Channels meldet der Server nicht q.send("servernotifyregister", "event", "channel", "id", "0");
socket.subscribeAll(); loadChannels(q);
awaitInitialState(); loadClients(q);
LOG.info("TeamSpeak verbunden mit {}:{} ({} Channels, {} Clients sichtbar)",
config.host(), config.port(), channels.size(), clients.size());
parentChannel = resolveParent(); parentChannel = resolveParent();
resolveChannels(socket); resolveChannels(q);
joinParent(socket);
ready = true; ready = true;
LOG.info("TeamSpeak-Query verbunden mit {}:{} ({} Channels, {} Clients)",
config.host(), config.queryPort(), channels.size(), clients.size());
} catch (Exception e) { } catch (Exception e) {
LOG.warn("TeamSpeak-Verbindung fehlgeschlagen ({}), neuer Versuch in {} s", e.toString(), RECONNECT_SECONDS); LOG.warn("TeamSpeak-Query fehlgeschlagen ({}), neuer Versuch in {} s", e.toString(), RECONNECT_SECONDS);
disconnectObserver(); closeQuery();
executor.schedule(this::connectObserver, RECONNECT_SECONDS, TimeUnit.SECONDS); executor.schedule(this::connectQuery, RECONNECT_SECONDS, TimeUnit.SECONDS);
} }
} }
/** ts3j liefert die Channel- und Client-Liste asynchron aus; warten, bis sie sich nicht mehr ändert. */ private void onQueryClosed() {
private void awaitInitialState() throws InterruptedException { if (query == null) return; // absichtlich geschlossen
int last = -1; LOG.warn("TeamSpeak-Query getrennt, neuer Versuch in {} s", RECONNECT_SECONDS);
for (int i = 0; i < 50; i++) { closeQuery();
int now = channels.size() + clients.size(); executor.schedule(this::connectQuery, RECONNECT_SECONDS, TimeUnit.SECONDS);
if (now == last && now > 0) return;
last = now;
Thread.sleep(200);
}
} }
private void resolveChannels(LocalTeamspeakClientSocket socket) throws InterruptedException { private void closeQuery() {
for (Room room : rooms) { ready = false;
if (roomChannels.containsKey(room.key())) continue; TsQuery q = query;
int configured = room.config().teamspeakChannel(); query = null;
if (configured > 0) { if (q != null) q.close();
roomChannels.put(room.key(), configured); clients.clear();
continue; channels.clear();
} }
Integer found = findChannel(room.name());
if (found == null) { /** Hält die Verbindung offen und gleicht verpasste Ereignisse aus. */
private void resync() {
TsQuery q = query;
if (!ready || q == null) return;
try { try {
socket.executeCommand(new SingleCommand("channelcreate", ProtocolRole.CLIENT, loadClients(q);
new CommandSingleParameter("channel_name", room.name()), if (rooms.stream().anyMatch(room -> !roomChannels.containsKey(room.key()))) resolveChannels(q);
new CommandSingleParameter("cpid", Integer.toString(parentChannel)), } catch (IOException e) {
new CommandSingleParameter("channel_flag_permanent", "1"), LOG.debug("TeamSpeak-Abgleich fehlgeschlagen: {}", e.toString());
new CommandSingleParameter("channel_codec", "4"))).get();
} catch (InterruptedException e) {
throw e;
} catch (Exception e) {
LOG.error("{}: TeamSpeak-Channel konnte nicht angelegt werden ({}). Fehlen dem Client "
+ "\"{}\" die Rechte zum Anlegen? Bis dahin ohne TeamSpeak.", room.name(), e, config.nickname());
continue;
}
// Die Antwort enthält die cid nicht, sie kommt per notifychannelcreated.
for (int i = 0; i < 50 && found == null; i++) {
Thread.sleep(100);
found = findChannel(room.name());
}
if (found == null) {
LOG.error("{}: TeamSpeak-Channel wurde nicht angelegt", room.name());
continue;
}
LOG.info("TeamSpeak-Channel angelegt: {} ({})", room.name(), found);
}
roomChannels.put(room.key(), found);
} }
} }
/** Der Beobachter wartet im Eltern-Channel; wer einen Channel anlegt, kann dorthin verschoben werden. */ private void loadChannels(TsQuery q) throws IOException {
private void joinParent(LocalTeamspeakClientSocket socket) throws Exception { Map<Integer, TsChannel> fresh = new ConcurrentHashMap<>();
TsClient self = clients.get(socket.getClientId()); for (Map<String, String> c : q.send("channellist")) {
if (parentChannel > 0 && (self == null || self.channel() != parentChannel)) socket.joinChannel(parentChannel, null); fresh.put(parse(c.get("cid"), -1), new TsChannel(parse(c.get("pid"), 0), c.getOrDefault("channel_name", "")));
}
channels.clear();
channels.putAll(fresh);
}
private void loadClients(TsQuery q) throws IOException {
Map<Integer, TsClient> fresh = new ConcurrentHashMap<>();
for (Map<String, String> c : q.send("clientlist")) {
fresh.put(parse(c.get("clid"), -1), new TsClient(parse(c.get("cid"), 0), parse(c.get("client_type"), 0)));
}
clients.clear();
clients.putAll(fresh);
} }
/** {@code parentChannel} ist eine Channel-ID oder der Name eines Channels auf oberster Ebene. */ /** {@code parentChannel} ist eine Channel-ID oder der Name eines Channels auf oberster Ebene. */
@@ -249,12 +211,32 @@ public final class TeamspeakSide implements TS3Listener {
if (id >= 0) return id; if (id >= 0) return id;
Integer found = findChannel(0, configured); Integer found = findChannel(0, configured);
if (found == null) throw new IOException("TeamSpeak-Channel \"" + configured + "\" gibt es nicht"); if (found == null) throw new IOException("TeamSpeak-Channel \"" + configured + "\" gibt es nicht");
LOG.info("TeamSpeak-Eltern-Channel {} hat die ID {}", configured, found);
return found; return found;
} }
private Integer findChannel(String name) { private void resolveChannels(TsQuery q) {
return findChannel(parentChannel, name); for (Room room : rooms) {
if (roomChannels.containsKey(room.key())) continue;
int configured = room.config().teamspeakChannel();
if (configured > 0) {
roomChannels.put(room.key(), configured);
continue;
}
Integer found = findChannel(parentChannel, room.name());
if (found == null) {
try {
found = parse(q.send("channelcreate", "channel_name", room.name(), "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);
} 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);
}
} }
private Integer findChannel(int parent, String name) { private Integer findChannel(int parent, String name) {
@@ -264,22 +246,28 @@ public final class TeamspeakSide implements TS3Listener {
return null; return null;
} }
private void disconnectObserver() { /** Lesethread der Query: nur Karten aktualisieren, keine Befehle schicken. */
ready = false; private void onNotification(TsQuery.Notification n) {
LocalTeamspeakClientSocket socket = observer; Map<String, String> f = n.fields();
observer = null; int clid = parse(f.get("clid"), -1);
if (socket != null) { int cid = parse(f.get("cid"), -1);
socket.removeListener(this); switch (n.name()) {
try { case "notifycliententerview" -> clients.put(clid, new TsClient(parse(f.get("ctid"), 0), parse(f.get("client_type"), 0)));
socket.disconnect(); case "notifyclientleftview" -> clients.remove(clid);
} catch (Exception ignored) { case "notifyclientmoved" -> clients.compute(clid, (id, old) ->
// Verbindung ist ohnehin weg new TsClient(parse(f.get("ctid"), 0), old == null ? CLIENT_TYPE_VOICE : old.type()));
case "notifychannelcreated" -> channels.put(cid, new TsChannel(parse(f.get("cpid"), 0), f.getOrDefault("channel_name", "")));
case "notifychanneledited" -> {
String name = f.get("channel_name");
if (name != null) channels.computeIfPresent(cid, (id, old) -> new TsChannel(old.parent(), name));
} }
case "notifychannelmoved" -> channels.computeIfPresent(cid, (id, old) -> new TsChannel(parse(f.get("cpid"), 0), old.name()));
case "notifychanneldeleted" -> {
channels.remove(cid);
roomChannels.values().removeIf(c -> c == cid);
}
default -> {}
} }
for (TeamspeakRoomClient client : roomClients.values()) client.disconnect();
roomClients.clear();
clients.clear();
channels.clear();
} }
LocalIdentity loadIdentity(String file) throws IOException, GeneralSecurityException { LocalIdentity loadIdentity(String file) throws IOException, GeneralSecurityException {
@@ -306,73 +294,9 @@ public final class TeamspeakSide implements TS3Listener {
public void stop() { public void stop() {
executor.shutdownNow(); executor.shutdownNow();
disconnectObserver(); for (TeamspeakRoomClient client : roomClients.values()) client.disconnect();
} roomClients.clear();
closeQuery();
// --- Ereignisse des Beobachters ---
@Override
public void onClientJoin(ClientJoinEvent e) {
clients.put(e.getClientId(), new TsClient(e.getClientTargetId(), e.getClientType()));
}
@Override
public void onClientMoved(ClientMovedEvent e) {
clients.compute(e.getClientId(), (id, old) ->
new TsClient(e.getTargetChannelId(), old == null ? CLIENT_TYPE_VOICE : old.type()));
}
@Override
public void onClientLeave(ClientLeaveEvent e) {
clients.remove(e.getClientId());
}
@Override
public void onChannelList(ChannelListEvent e) {
putChannel(e.getMap());
}
@Override
public void onChannelCreate(ChannelCreateEvent e) {
putChannel(e.getMap());
}
@Override
public void onChannelEdit(ChannelEditedEvent e) {
Map<String, String> map = e.getMap();
String name = map.get("channel_name");
int cid = parse(map.get("cid"), -1);
if (name != null && cid >= 0) channels.computeIfPresent(cid, (id, old) -> new TsChannel(old.parent(), name));
}
@Override
public void onChannelMoved(ChannelMovedEvent e) {
Map<String, String> map = e.getMap();
int cid = parse(map.get("cid"), -1);
int parent = parse(map.get("cpid"), 0);
channels.computeIfPresent(cid, (id, old) -> new TsChannel(parent, old.name()));
}
@Override
public void onChannelDeleted(ChannelDeletedEvent e) {
channels.remove(e.getChannelId());
roomChannels.values().removeIf(cid -> cid == e.getChannelId());
}
@Override
public void onDisconnected(DisconnectedEvent e) {
// Beim fehlgeschlagenen Verbinden plant connectObserver den Neuversuch selbst
if (!ready) return;
ready = false;
LOG.warn("TeamSpeak-Beobachter getrennt, neuer Versuch in {} s", RECONNECT_SECONDS);
executor.execute(this::disconnectObserver);
executor.schedule(this::connectObserver, RECONNECT_SECONDS, TimeUnit.SECONDS);
}
private void putChannel(Map<String, String> map) {
int cid = parse(map.get("cid"), -1);
if (cid < 0) return;
channels.put(cid, new TsChannel(parse(map.get("cpid"), 0), map.getOrDefault("channel_name", "")));
} }
private static int parse(String value, int def) { private static int parse(String value, int def) {
@@ -0,0 +1,207 @@
package eu.mhsl.voicebridge.bridge.teamspeak;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
* ServerQuery über SSH (Port 10022). Query-Verbindungen belegen keinen Client-Slot.
* Antworten kommen als Datenzeilen und einer abschließenden {@code error id=…}-Zeile,
* Ereignisse (nach {@code servernotifyregister}) als {@code notify…}-Zeilen dazwischen.
*/
public final class TsQuery implements AutoCloseable {
public static final class QueryException extends IOException {
public final int id;
QueryException(int id, String message) {
super("Fehler " + id + ": " + message);
this.id = id;
}
}
/** Ein Ereignis: Name (z. B. {@code notifycliententerview}) und seine Felder. */
public record Notification(String name, Map<String, String> fields) {}
private final Session session;
private final ChannelShell channel;
private final OutputStream out;
private final BlockingQueue<Object> responses = new ArrayBlockingQueue<>(4);
private final Consumer<Notification> notifications;
private final Runnable onClose;
private final Thread reader;
private volatile boolean closed;
private long lastCommand;
/** Mindestabstand zwischen zwei Befehlen (die Query sperrt die IP bei zu vielen Befehlen). */
private static final long MIN_INTERVAL_MILLIS = 1000;
public TsQuery(String host, int port, String user, String password,
Consumer<Notification> notifications, Runnable onClose) throws JSchException, IOException {
this.notifications = notifications;
this.onClose = onClose;
JSch jsch = new JSch();
session = jsch.getSession(user, host, port);
session.setPassword(password);
// TeamSpeak erzeugt seinen Host-Key selbst, es gibt nichts zum Abgleichen
session.setConfig("StrictHostKeyChecking", "no");
session.setServerAliveInterval(60_000);
session.connect(15_000);
channel = (ChannelShell) session.openChannel("shell");
channel.setPty(false);
BufferedReader in = new BufferedReader(new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8));
out = channel.getOutputStream();
channel.connect(15_000);
reader = Thread.ofPlatform().daemon().name("ts-query").start(() -> readLoop(in));
}
private void readLoop(BufferedReader in) {
List<Map<String, String>> data = new ArrayList<>();
try {
String line;
while ((line = in.readLine()) != null) {
line = line.strip();
if (line.isEmpty() || line.equals("TS3") || line.startsWith("Welcome") || line.startsWith("Please type")) continue;
if (line.startsWith("notify")) {
int space = line.indexOf(' ');
String name = space < 0 ? line : line.substring(0, space);
for (Map<String, String> entry : parse(space < 0 ? "" : line.substring(space + 1))) {
notifications.accept(new Notification(name, entry));
}
} else if (line.startsWith("error ")) {
Map<String, String> error = parse(line.substring(6)).getFirst();
int id = Integer.parseInt(error.getOrDefault("id", "-1"));
responses.put(id == 0 ? List.copyOf(data) : new QueryException(id, error.getOrDefault("msg", "")));
data.clear();
} else {
data.addAll(parse(line));
}
}
} catch (IOException | InterruptedException e) {
// Verbindung beendet
} finally {
closed = true;
responses.offer(new IOException("Query-Verbindung getrennt"));
onClose.run();
}
}
/** Befehl schicken und auf die Antwort warten. Flags wie {@code -uid} gehören in {@code command}. */
@SuppressWarnings("unchecked")
public synchronized List<Map<String, String>> send(String command, String... keyValues) throws IOException {
if (closed) throw new IOException("Query-Verbindung getrennt");
StringBuilder line = new StringBuilder(command);
for (int i = 0; i + 1 < keyValues.length; i += 2) {
line.append(' ').append(keyValues[i]).append('=').append(escape(keyValues[i + 1]));
}
// Flood-Schutz der Query: lieber langsam als eine IP-Sperre
long wait = lastCommand + MIN_INTERVAL_MILLIS - System.currentTimeMillis();
if (wait > 0) {
try {
Thread.sleep(wait);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("unterbrochen", e);
}
}
lastCommand = System.currentTimeMillis();
responses.clear();
out.write((line + "\n").getBytes(StandardCharsets.UTF_8));
out.flush();
Object response;
try {
response = responses.poll(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("unterbrochen", e);
}
if (response == null) throw new IOException("Keine Antwort auf " + command);
if (response instanceof IOException e) throw e;
return (List<Map<String, String>>) response;
}
public boolean isOpen() {
return !closed;
}
@Override
public void close() {
closed = true;
try {
out.write("quit\n".getBytes(StandardCharsets.UTF_8));
out.flush();
} catch (IOException ignored) {
// Verbindung ist ohnehin weg
}
channel.disconnect();
session.disconnect();
}
static List<Map<String, String>> parse(String text) {
List<Map<String, String>> entries = new ArrayList<>();
for (String entry : text.split("\\|")) {
Map<String, String> map = new LinkedHashMap<>();
for (String token : entry.split(" ")) {
if (token.isEmpty()) continue;
int eq = token.indexOf('=');
if (eq < 0) map.put(token, "");
else map.put(token.substring(0, eq), unescape(token.substring(eq + 1)));
}
entries.add(map);
}
return entries;
}
static String escape(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (char c : value.toCharArray()) {
switch (c) {
case '\\' -> sb.append("\\\\");
case '/' -> sb.append("\\/");
case ' ' -> sb.append("\\s");
case '|' -> sb.append("\\p");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> sb.append(c);
}
}
return sb.toString();
}
static String unescape(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c != '\\' || i + 1 == value.length()) {
sb.append(c);
continue;
}
char next = value.charAt(++i);
switch (next) {
case 's' -> sb.append(' ');
case 'p' -> sb.append('|');
case '/' -> sb.append('/');
case 'n' -> sb.append('\n');
case 'r' -> sb.append('\r');
case 't' -> sb.append('\t');
case '\\' -> sb.append('\\');
default -> sb.append(next);
}
}
return sb.toString();
}
}
@@ -0,0 +1,33 @@
package eu.mhsl.voicebridge.bridge.teamspeak;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class TsQueryTest {
@Test
void escapeRoundTrip() {
String value = "Crosstalk 1 (MC/DC) | a\\b";
assertEquals("Crosstalk\\s1\\s(MC\\/DC)\\s\\p\\sa\\\\b", TsQuery.escape(value));
assertEquals(value, TsQuery.unescape(TsQuery.escape(value)));
}
@Test
void parsesListEntries() {
List<Map<String, String>> entries = TsQuery.parse(
"clid=5 cid=265 client_nickname=Crosstalk\\s1\\s(MC\\/DC) client_type=0|clid=7 cid=1 client_type=1");
assertEquals(2, entries.size());
assertEquals("Crosstalk 1 (MC/DC)", entries.get(0).get("client_nickname"));
assertEquals("265", entries.get(0).get("cid"));
assertEquals("1", entries.get(1).get("client_type"));
}
@Test
void parsesFlagsWithoutValue() {
assertEquals("", TsQuery.parse("cid=3 channel_flag_permanent").getFirst().get("channel_flag_permanent"));
}
}