diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Announcer.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Announcer.java new file mode 100644 index 0000000..8c1da8e --- /dev/null +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Announcer.java @@ -0,0 +1,155 @@ +package eu.mhsl.voicebridge.bridge; + +import eu.mhsl.voicebridge.bridge.Room.Side; +import eu.mhsl.voicebridge.bridge.audio.Mixer; +import eu.mhsl.voicebridge.bridge.audio.Opus; +import eu.mhsl.voicebridge.bridge.audio.Tones; +import eu.mhsl.voicebridge.bridge.discord.DiscordRoomBot; +import eu.mhsl.voicebridge.bridge.teamspeak.TeamspeakSide; +import io.github.jaredmdobson.concentus.OpusEncoder; +import io.github.jaredmdobson.concentus.OpusException; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Macht sichtbar, wer auf den anderen Plattformen ist: Beitritt/Verlassen als Text und Signalton, + * Zusammenfassung wenn ein Bot dazukommt, Discord-Voice-Status und TS-Channel-Thema. + * Hinweise gehen immer nur an die anderen Plattformen, die eigene zeigt das ja selbst an. + */ +final class Announcer { + /** Discord und TeamSpeak drosseln Änderungen, öfter als alle 5 s wird Status/Thema nicht gesetzt. */ + private static final long STATUS_INTERVAL_MILLIS = 5000; + + private final PluginLink plugin; + private final TeamspeakSide teamspeak; + private final ScheduledExecutorService scheduler; + private final Map states = new HashMap<>(); + private final AtomicLong toneCounter = new AtomicLong(); + + private static final class State { + final Map> roster = new EnumMap<>(Side.class); + boolean teamspeakConnected, discordInVoice; + String discordStatus = "", teamspeakTopic = ""; + long lastStatus; + } + + Announcer(PluginLink plugin, TeamspeakSide teamspeak, ScheduledExecutorService scheduler) { + this.plugin = plugin; + this.teamspeak = teamspeak; + this.scheduler = scheduler; + } + + /** + * Ein Takt für einen Raum. + * + * @param roster wer auf welcher Seite ist (ID → Name); fehlt eine Seite, ist sie gerade nicht verbunden + * und wird nicht verglichen (sonst gäbe es nach jedem Reconnect eine Flut von Meldungen) + */ + void update(Room room, DiscordRoomBot bot, Map> roster, boolean active) { + State state = states.computeIfAbsent(room.key(), k -> new State()); + + if (active) { + for (Side side : Side.values()) { + Map before = state.roster.get(side); + Map now = roster.get(side); + if (before == null || now == null) continue; + now.forEach((id, name) -> { if (!before.containsKey(id)) announce(room, bot, roster, side, name, true); }); + before.forEach((id, name) -> { if (!now.containsKey(id)) announce(room, bot, roster, side, name, false); }); + } + } + state.roster.clear(); + state.roster.putAll(roster); + + // Zusammenfassung, sobald ein Bot im Channel ist + boolean tsConnected = teamspeak != null && teamspeak.roomConnected(room); + if (tsConnected && !state.teamspeakConnected) { + String others = others(roster, Side.TEAMSPEAK, " | "); + if (!others.isEmpty()) teamspeak.sendRoomMessage(room, "Verbunden mit " + others); + } + state.teamspeakConnected = tsConnected; + + boolean dcInVoice = bot != null && bot.inVoice(); + if (dcInVoice && !state.discordInVoice) { + String others = others(roster, Side.DISCORD, " | "); + if (!others.isEmpty()) bot.sendText("Verbunden mit " + others); + state.discordStatus = ""; // nach dem Beitritt neu setzen + } + state.discordInVoice = dcInVoice; + + // Wer ist da: Discord-Status und TS-Thema + long now = System.currentTimeMillis(); + if (now - state.lastStatus < STATUS_INTERVAL_MILLIS) return; + String dcStatus = active ? others(roster, Side.DISCORD, " · ") : ""; + String tsTopic = active ? others(roster, Side.TEAMSPEAK, " · ") : ""; + if (dcInVoice && !dcStatus.equals(state.discordStatus)) { + bot.setStatus(dcStatus); + state.discordStatus = dcStatus; + state.lastStatus = now; + } + if (teamspeak != null && teamspeak.ready() && !tsTopic.equals(state.teamspeakTopic)) { + teamspeak.setRoomTopic(room, tsTopic); + state.teamspeakTopic = tsTopic; + state.lastStatus = now; + } + } + + private void announce(Room room, DiscordRoomBot bot, Map> roster, + Side side, String name, boolean joined) { + String text = name + " (" + Room.label(side) + ") ist " + (joined ? "dazugekommen." : "gegangen."); + List tone = joined ? Tones.JOIN : Tones.LEAVE; + + if (side != Side.MINECRAFT && !roster.getOrDefault(Side.MINECRAFT, Map.of()).isEmpty()) { + plugin.sendNotice(room.key(), text); + playToMinecraft(room, tone); + } + if (side != Side.TEAMSPEAK && teamspeak != null && teamspeak.roomConnected(room)) { + teamspeak.sendRoomMessage(room, text); + push(room.toTeamspeak(), tone); + } + if (side != Side.DISCORD && bot != null && !roster.getOrDefault(Side.DISCORD, Map.of()).isEmpty()) { + bot.sendText(text); + if (bot.inVoice()) push(room.toDiscord(), tone); + } + } + + /** "Minecraft: Steve, Alex | Discord: Anna" ohne die Seite {@code except}. */ + static String others(Map> roster, Side except, String separator) { + List parts = new ArrayList<>(); + for (Side side : Side.values()) { + Map people = roster.get(side); + if (side == except || people == null || people.isEmpty()) continue; + parts.add(Room.label(side) + ": " + String.join(", ", people.values())); + } + return String.join(separator, parts); + } + + private void push(Mixer mixer, List tone) { + String source = "tone:" + toneCounter.incrementAndGet(); + for (short[] frame : tone) mixer.push(source, frame); + } + + /** Zum Plugin im 20-ms-Takt, wie echte Sprache, sonst verschluckt der Voicechat-Client die Frames. */ + private void playToMinecraft(Room room, List tone) { + UUID source = UUID.nameUUIDFromBytes(("voicebridge-tone:" + room.key()).getBytes(StandardCharsets.UTF_8)); + OpusEncoder encoder = Opus.encoder(1, 48000); + for (int i = 0; i < tone.size(); i++) { + short[] frame = tone.get(i); + scheduler.schedule(() -> { + try { + plugin.sendAudio(room.key(), source, Opus.encodeMono(encoder, frame)); + } catch (OpusException ignored) { + // ein fehlender Ton-Frame ist egal + } + }, i * 20L, TimeUnit.MILLISECONDS); + } + } +} 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 ebd503f..7c758ce 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Bridge.java @@ -7,7 +7,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; +import eu.mhsl.voicebridge.bridge.Room.Side; + import java.util.ArrayList; +import java.util.EnumMap; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -33,6 +36,9 @@ public final class Bridge { private final TeamspeakSide teamspeak; private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor( Thread.ofPlatform().name("bridge-tick").factory()); + private final Announcer announcer; + /** Wie lange nach dem letzten Paket jemand noch als "spricht" gilt. */ + private static final long SPEAKING_MILLIS = 400; private volatile List roomInfos = List.of(); @@ -57,6 +63,15 @@ public final class Bridge { this.teamspeak = config.teamspeak().enabled() ? new TeamspeakSide(config.teamspeak(), config.dataDir(), List.copyOf(rooms.values()), updater) : null; + for (Room room : rooms.values()) { + DiscordRoomBot bot = discordBots.get(room.key()); + room.setNameResolver((side, id) -> switch (side) { + case TEAMSPEAK -> teamspeak != null ? teamspeak.nickname(Integer.parseInt(id)) : id; + case DISCORD -> bot != null ? bot.name(Long.parseLong(id)) : id; + case MINECRAFT -> id; + }); + } + this.announcer = new Announcer(plugin, teamspeak, ticker); roomInfos = buildInfos(); } @@ -67,6 +82,7 @@ public final class Bridge { if (teamspeak != null) teamspeak.start(); discordBots.values().forEach(DiscordRoomBot::start); ticker.scheduleAtFixedRate(this::safeTick, 1, 1, TimeUnit.SECONDS); + ticker.scheduleAtFixedRate(this::sendSpeakers, 500, 500, TimeUnit.MILLISECONDS); } private void safeTick() { @@ -84,9 +100,10 @@ public final class Bridge { for (Room room : rooms.values()) { Presence p = presence.get(room.key()); DiscordRoomBot bot = discordBots.get(room.key()); - int mc = plugin.minecraftMembers(room.key()); - int ts = teamspeak != null ? teamspeak.humansIn(room) : 0; - int dc = bot != null ? bot.humans() : 0; + Map> roster = roster(room, bot); + int mc = roster.getOrDefault(Side.MINECRAFT, Map.of()).size(); + int ts = roster.getOrDefault(Side.TEAMSPEAK, Map.of()).size(); + int dc = roster.getOrDefault(Side.DISCORD, Map.of()).size(); String counts = "Minecraft " + mc + ", TeamSpeak " + ts + ", Discord " + dc; if (!counts.equals(p.lastCounts)) { LOG.info("{}: {}", room.name(), counts); @@ -118,18 +135,45 @@ public final class Bridge { p.lastStats = 0; } + try { + announcer.update(room, bot, roster, active); + } catch (RuntimeException e) { + LOG.warn("{}: Anzeige fehlgeschlagen", room.name(), e); + } room.dropIdleSources(); } roomInfos = buildInfos(); } + /** Wer auf welcher Seite ist; Seiten, die gerade nicht verbunden sind, fehlen in der Map. */ + private Map> roster(Room room, DiscordRoomBot bot) { + Map> roster = new EnumMap<>(Side.class); + if (plugin.alive()) roster.put(Side.MINECRAFT, plugin.minecraftMembers(room.key())); + if (teamspeak != null && teamspeak.ready()) roster.put(Side.TEAMSPEAK, teamspeak.participants(room)); + if (bot != null && bot.ready()) roster.put(Side.DISCORD, bot.participants()); + return roster; + } + + /** Actionbar im Spiel: wer aus Discord/TeamSpeak gerade spricht. */ + private void sendSpeakers() { + try { + for (Room room : rooms.values()) { + if (!room.active()) continue; + List speakers = room.externalSpeakers(SPEAKING_MILLIS); + if (!speakers.isEmpty()) plugin.sendSpeakers(room.key(), speakers); + } + } catch (RuntimeException e) { + LOG.warn("Sprecher-Anzeige fehlgeschlagen", e); + } + } + private List buildInfos() { List infos = new ArrayList<>(rooms.size()); for (Room room : rooms.values()) { DiscordRoomBot bot = discordBots.get(room.key()); infos.add(new Message.RoomInfo(room.key(), room.name(), room.groupId(), - teamspeak != null ? teamspeak.humansIn(room) : 0, - bot != null ? bot.humans() : 0, + teamspeak != null ? teamspeak.participants(room).size() : 0, + bot != null ? bot.participants().size() : 0, room.active())); } return infos; diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/PluginLink.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/PluginLink.java index 845eeff..dacbaa2 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/PluginLink.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/PluginLink.java @@ -13,6 +13,7 @@ import java.net.SocketAddress; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; @@ -25,7 +26,7 @@ final class PluginLink { private final DatagramSocket socket; private final Map rooms; private final Supplier> roomInfos; - private final Map minecraftMembers = new ConcurrentHashMap<>(); + private final Map> minecraftMembers = new ConcurrentHashMap<>(); private volatile SocketAddress plugin; private volatile long lastHello; @@ -47,8 +48,22 @@ final class PluginLink { return System.currentTimeMillis() - lastHello < ALIVE_MILLIS; } - int minecraftMembers(String room) { - return alive() ? minecraftMembers.getOrDefault(room, 0) : 0; + /** Spieler in der Gruppe (UUID → Name), leer solange das Plugin sich nicht meldet. */ + Map minecraftMembers(String room) { + if (!alive()) return Map.of(); + Map result = new TreeMap<>(); + for (Message.Member member : minecraftMembers.getOrDefault(room, List.of())) result.put(member.id().toString(), member.name()); + return result; + } + + void sendNotice(String room, String text) { + SocketAddress target = plugin; + if (target != null && alive()) send(new Message.Notice(room, text), target); + } + + void sendSpeakers(String room, List names) { + SocketAddress target = plugin; + if (target != null && alive()) send(new Message.Speakers(room, names), target); } void sendAudio(String room, UUID source, byte[] opus) { @@ -105,6 +120,8 @@ final class PluginLink { if (room != null) room.fromMinecraft(audio.source(), audio.opus()); } case Message.Rooms ignored -> {} + case Message.Notice ignored -> {} + case Message.Speakers ignored -> {} } } diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Room.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Room.java index c056f14..b12f60a 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Room.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/Room.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.BiFunction; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -35,6 +36,9 @@ public final class Room { private final Mixer toDiscord = new Mixer(); private final Map sources = new ConcurrentHashMap<>(); + /** Anzeigename eines Sprechers (Seite, ID ohne Präfix), für die Actionbar. */ + private volatile BiFunction names = (side, id) -> id; + /** Wohin Frames für Minecraft gehen (Plugin-Verbindung). */ private volatile MinecraftSink minecraft = (room, source, opus) -> {}; @@ -55,6 +59,7 @@ public final class Room { final FrameSplitter splitter = new FrameSplitter(); OpusEncoder minecraftEncoder; volatile long lastSeen; + volatile String name; int lastSequence = -1; Source(Side side, String key) { @@ -78,6 +83,24 @@ public final class Room { public Mixer toDiscord() { return toDiscord; } public void setMinecraftSink(MinecraftSink sink) { this.minecraft = sink; } + public void setNameResolver(BiFunction names) { this.names = names; } + + /** Wer aus Discord/TeamSpeak in den letzten {@code millis} Audio geschickt hat, als "Name (Plattform)". */ + public List externalSpeakers(long millis) { + long since = System.currentTimeMillis() - millis; + return sources.values().stream() + .filter(s -> s.side != Side.MINECRAFT && s.lastSeen >= since) + .map(s -> s.name + " (" + label(s.side) + ")") + .sorted().toList(); + } + + public static String label(Side side) { + return switch (side) { + case MINECRAFT -> "Minecraft"; + case TEAMSPEAK -> "TeamSpeak"; + case DISCORD -> "Discord"; + }; + } void setActive(boolean active) { if (this.active == active) return; @@ -109,6 +132,7 @@ public final class Room { if (!active || opus.length == 0) return; Source source = sources.computeIfAbsent(key, k -> new Source(side, k)); source.lastSeen = System.currentTimeMillis(); + if (source.name == null) source.name = names.apply(side, key.substring(3)); List frames = new ArrayList<>(2); try { diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/audio/Tones.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/audio/Tones.java new file mode 100644 index 0000000..d7a5cf7 --- /dev/null +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/audio/Tones.java @@ -0,0 +1,41 @@ +package eu.mhsl.voicebridge.bridge.audio; + +import java.util.ArrayList; +import java.util.List; + +/** Kurze Signaltöne für Beitritt (aufsteigend) und Verlassen (absteigend), als 20-ms-Frames. */ +public final class Tones { + private static final int AMPLITUDE = 3500; + private static final int NOTE_MILLIS = 80; + private static final int GAP_MILLIS = 20; + + public static final List JOIN = frames(660, 990); + public static final List LEAVE = frames(990, 660); + + private Tones() {} + + private static List frames(double first, double second) { + int note = Opus.SAMPLE_RATE * NOTE_MILLIS / 1000; + int gap = Opus.SAMPLE_RATE * GAP_MILLIS / 1000; + short[] pcm = new short[2 * note + gap]; + tone(pcm, 0, note, first); + tone(pcm, note + gap, note, second); + + List frames = new ArrayList<>(); + for (int offset = 0; offset < pcm.length; offset += Opus.FRAME_SAMPLES) { + short[] frame = new short[Opus.FRAME_SAMPLES]; + System.arraycopy(pcm, offset, frame, 0, Math.min(Opus.FRAME_SAMPLES, pcm.length - offset)); + frames.add(frame); + } + return List.copyOf(frames); + } + + /** Sinus mit kurzem Ein- und Ausblenden, damit es nicht knackt. */ + private static void tone(short[] pcm, int start, int length, double frequency) { + int fade = length / 8; + for (int i = 0; i < length; i++) { + double envelope = Math.min(1.0, Math.min(i, length - 1 - i) / (double) fade); + pcm[start + i] = (short) (AMPLITUDE * envelope * Math.sin(2 * Math.PI * frequency * i / Opus.SAMPLE_RATE)); + } + } +} 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 ba19ef1..3e0740e 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 @@ -16,11 +16,13 @@ import net.dv8tion.jda.api.audio.OpusPacket; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.concrete.Category; +import net.dv8tion.jda.api.entities.channel.attribute.IVoiceStatusChannel; import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; import net.dv8tion.jda.api.events.guild.GuildJoinEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.managers.AudioManager; import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.utils.MarkdownSanitizer; import net.dv8tion.jda.api.utils.MemberCachePolicy; import net.dv8tion.jda.api.utils.cache.CacheFlag; import org.jetbrains.annotations.NotNull; @@ -29,6 +31,8 @@ import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.util.List; +import java.util.Map; +import java.util.TreeMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -137,15 +141,56 @@ public final class DiscordRoomBot implements AudioSendHandler, AudioReceiveHandl return channel; } - /** Menschen im Raum-Channel (Bots zählen nicht). */ - public int humans() { + /** Menschen im Raum-Channel (User-ID → Anzeigename), Bots zählen nicht. */ + public Map participants() { + VoiceChannel channel = channel(); + if (channel == null) return Map.of(); + Map result = new TreeMap<>(); + for (Member member : channel.getMembers()) { + if (!member.getUser().isBot()) result.put(member.getId(), member.getEffectiveName()); + } + return result; + } + + /** Anzeigename eines Users, für die Sprecher-Anzeige. */ + public String name(long userId) { Guild guild = this.guild; - if (guild == null || channelId == 0) return 0; - VoiceChannel channel = guild.getVoiceChannelById(channelId); - if (channel == null) return 0; - int count = 0; - for (Member member : channel.getMembers()) if (!member.getUser().isBot()) count++; - return count; + Member member = guild == null ? null : guild.getMemberById(userId); + return member == null ? "?" : member.getEffectiveName(); + } + + /** Ob der Bot eingeloggt ist und seinen Channel kennt. */ + public boolean ready() { + return guild != null && channelId != 0; + } + + /** Ob der Bot gerade im Voice-Channel ist. */ + public boolean inVoice() { + Guild guild = this.guild; + return guild != null && joined && guild.getAudioManager().isConnected(); + } + + /** Nachricht im Text-Chat des Voice-Channels. */ + public void sendText(String text) { + VoiceChannel channel = channel(); + if (channel == null) return; + channel.sendMessage(MarkdownSanitizer.escape(text)).setSuppressedNotifications(true).queue(null, + e -> LOG.warn("{}: Nachricht nicht gesendet ({}). Hat der Bot \"Nachrichten senden\"?", room.name(), e.getMessage())); + } + + /** Status unter dem Voice-Channel (leer = löschen). Geht nur, solange der Bot im Channel ist. */ + public void setStatus(String status) { + VoiceChannel channel = channel(); + if (channel == null) return; + String text = status.length() > IVoiceStatusChannel.MAX_STATUS_LENGTH + ? status.substring(0, IVoiceStatusChannel.MAX_STATUS_LENGTH) : status; + channel.modifyStatus(text).queue(null, + e -> LOG.warn("{}: Status nicht gesetzt ({}). Hat der Bot \"Voice-Channel-Status setzen\"?", room.name(), e.getMessage())); + } + + private VoiceChannel channel() { + Guild guild = this.guild; + return guild == null || channelId == 0 ? null : guild.getVoiceChannelById(channelId); } public void setConnected(boolean connected) { diff --git a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakRoomClient.java b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakRoomClient.java index 44897f6..eb54d24 100644 --- a/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakRoomClient.java +++ b/bridge/src/main/java/eu/mhsl/voicebridge/bridge/teamspeak/TeamspeakRoomClient.java @@ -65,6 +65,18 @@ final class TeamspeakRoomClient implements Microphone, TS3Listener { return socket.getClientId(); } + boolean connected() { + return !closing && socket.isConnected(); + } + + void sendChannelMessage(String text) { + try { + socket.sendChannelMessage(channel, text); + } catch (Exception e) { + LOG.debug("{}: Textnachricht nicht gesendet: {}", room.name(), e.toString()); + } + } + void disconnect() { closing = true; try { 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 accda3c..3dac15b 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 @@ -15,6 +15,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -34,7 +35,7 @@ public final class TeamspeakSide { private static final long RETRY_MIN_MILLIS = 30_000; private static final long RETRY_MAX_MILLIS = 300_000; - private record TsClient(int channel, int type) {} + private record TsClient(int channel, int type, String name) {} private record TsChannel(int parent, String name) {} private final BridgeConfig.Teamspeak config; @@ -73,17 +74,51 @@ public final class TeamspeakSide { return ready; } - /** Menschen im Raum-Channel, ohne unsere eigenen Clients und ohne ServerQuery. */ - public int humansIn(Room room) { + /** Menschen im Raum-Channel (Client-ID → Nickname), ohne unsere eigenen Clients und ohne ServerQuery. */ + public Map participants(Room room) { Integer channel = roomChannels.get(room.key()); - if (!ready || channel == null) return 0; + if (!ready || channel == null) return Map.of(); Set own = ownClientIds(); - int count = 0; + Map result = new TreeMap<>(); for (Map.Entry e : clients.entrySet()) { TsClient client = e.getValue(); - if (client.channel() == channel && client.type() == CLIENT_TYPE_VOICE && !own.contains(e.getKey())) count++; + if (client.channel() == channel && client.type() == CLIENT_TYPE_VOICE && !own.contains(e.getKey())) { + result.put(Integer.toString(e.getKey()), client.name()); + } } - return count; + return result; + } + + /** Nickname eines Clients, für die Sprecher-Anzeige. */ + public String nickname(int clientId) { + TsClient client = clients.get(clientId); + return client == null ? "?" : client.name(); + } + + /** Ob der Raum-Client gerade im Channel ist. */ + public boolean roomConnected(Room room) { + TeamspeakRoomClient client = roomClients.get(room.key()); + return client != null && client.connected(); + } + + /** Textnachricht des Raum-Clients in seinen Channel. */ + public void sendRoomMessage(Room room, String text) { + TeamspeakRoomClient client = roomClients.get(room.key()); + if (client != null && client.connected()) executor.execute(() -> client.sendChannelMessage(text)); + } + + /** Channel-Thema per Query setzen (leer = löschen). */ + public void setRoomTopic(Room room, String topic) { + Integer channel = roomChannels.get(room.key()); + TsQuery q = query; + if (!ready || q == null || channel == null) return; + executor.execute(() -> { + try { + q.send("channeledit", "cid", Integer.toString(channel), "channel_topic", topic); + } catch (IOException e) { + LOG.warn("{}: Channel-Thema nicht gesetzt: {}", room.name(), e.getMessage()); + } + }); } /** @@ -200,7 +235,8 @@ public final class TeamspeakSide { private void loadClients(TsQuery q) throws IOException { Map fresh = new ConcurrentHashMap<>(); for (Map c : q.send("clientlist")) { - fresh.put(parse(c.get("clid"), -1), new TsClient(parse(c.get("cid"), 0), parse(c.get("client_type"), 0))); + fresh.put(parse(c.get("clid"), -1), new TsClient(parse(c.get("cid"), 0), parse(c.get("client_type"), 0), + c.getOrDefault("client_nickname", "?"))); } clients.clear(); clients.putAll(fresh); @@ -269,10 +305,15 @@ public final class TeamspeakSide { int clid = parse(f.get("clid"), -1); int cid = parse(f.get("cid"), -1); switch (n.name()) { - case "notifycliententerview" -> clients.put(clid, new TsClient(parse(f.get("ctid"), 0), parse(f.get("client_type"), 0))); + case "notifycliententerview" -> clients.put(clid, new TsClient(parse(f.get("ctid"), 0), parse(f.get("client_type"), 0), + f.getOrDefault("client_nickname", "?"))); case "notifyclientleftview" -> clients.remove(clid); case "notifyclientmoved" -> clients.compute(clid, (id, old) -> - new TsClient(parse(f.get("ctid"), 0), old == null ? CLIENT_TYPE_VOICE : old.type())); + new TsClient(parse(f.get("ctid"), 0), old == null ? CLIENT_TYPE_VOICE : old.type(), old == null ? "?" : old.name())); + case "notifyclientupdated" -> { + String name = f.get("client_nickname"); + if (name != null) clients.computeIfPresent(clid, (id, old) -> new TsClient(old.channel(), old.type(), name)); + } case "notifychannelcreated" -> channels.put(cid, new TsChannel(parse(f.get("cpid"), 0), f.getOrDefault("channel_name", ""))); case "notifychanneledited" -> { String name = f.get("channel_name"); diff --git a/bridge/src/test/java/eu/mhsl/voicebridge/bridge/AnnouncerTest.java b/bridge/src/test/java/eu/mhsl/voicebridge/bridge/AnnouncerTest.java new file mode 100644 index 0000000..5dc2f29 --- /dev/null +++ b/bridge/src/test/java/eu/mhsl/voicebridge/bridge/AnnouncerTest.java @@ -0,0 +1,25 @@ +package eu.mhsl.voicebridge.bridge; + +import eu.mhsl.voicebridge.bridge.Room.Side; +import org.junit.jupiter.api.Test; + +import java.util.EnumMap; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.*; + +class AnnouncerTest { + + @Test + void othersSkipsOwnAndEmptySides() { + Map> roster = new EnumMap<>(Side.class); + roster.put(Side.MINECRAFT, new TreeMap<>(Map.of("a", "Alex", "b", "Steve"))); + roster.put(Side.TEAMSPEAK, Map.of()); + roster.put(Side.DISCORD, Map.of("1", "Anna")); + + assertEquals("Minecraft: Alex, Steve", Announcer.others(roster, Side.DISCORD, " · ")); + assertEquals("Minecraft: Alex, Steve | Discord: Anna", Announcer.others(roster, Side.TEAMSPEAK, " | ")); + assertEquals("Discord: Anna", Announcer.others(roster, Side.MINECRAFT, " · ")); + } +} diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/ActionbarCommand.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/ActionbarCommand.java new file mode 100644 index 0000000..14bee94 --- /dev/null +++ b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/ActionbarCommand.java @@ -0,0 +1,49 @@ +package eu.mhsl.voicebridge.plugin; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.NamespacedKey; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Player; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** {@code /crosstalk actionbar}: die Sprecher-Anzeige für sich selbst aus- und einschalten. */ +final class ActionbarCommand implements TabExecutor { + private static NamespacedKey key; + + static void init(Plugin plugin) { + key = new NamespacedKey(plugin, "actionbar_off"); + } + + /** Main-Thread: ob der Spieler die Actionbar sehen will (Standard: ja). */ + static boolean enabled(Player player) { + return !player.getPersistentDataContainer().has(key, PersistentDataType.BYTE); + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String @NotNull [] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage("Nur für Spieler."); + return true; + } + if (args.length != 1 || !args[0].equalsIgnoreCase("actionbar")) return false; + + boolean nowEnabled = !enabled(player); + if (nowEnabled) player.getPersistentDataContainer().remove(key); + else player.getPersistentDataContainer().set(key, PersistentDataType.BYTE, (byte) 1); + player.sendMessage(Component.text("Anzeige, wer aus Discord/TeamSpeak spricht: " + + (nowEnabled ? "an" : "aus"), NamedTextColor.GRAY)); + return true; + } + + @Override + public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String @NotNull [] args) { + return args.length == 1 ? List.of("actionbar") : List.of(); + } +} diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeLink.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeLink.java index 608a5e9..99aaccc 100644 --- a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeLink.java +++ b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeLink.java @@ -8,6 +8,7 @@ import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetSocketAddress; import java.net.SocketException; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.Consumer; @@ -43,7 +44,7 @@ final class BridgeLink { return System.currentTimeMillis() - lastReply < ALIVE_MILLIS; } - void sendHello(Map members) { + void sendHello(Map> members) { boolean alive = isBridgeAlive(); if (wasAlive && !alive) logger.warning("Bridge antwortet nicht mehr."); wasAlive = alive; diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeVoicechat.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeVoicechat.java index b92cd2f..2ec5f29 100644 --- a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeVoicechat.java +++ b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/BridgeVoicechat.java @@ -101,7 +101,9 @@ final class BridgeVoicechat implements VoicechatPlugin { if (api == null || link == null) return; Map> byGroup = new HashMap<>(); + Map names = new HashMap<>(); for (Player player : Bukkit.getOnlinePlayers()) { + names.put(player.getUniqueId(), player.getName()); VoicechatConnection connection = api.getConnectionOf(player.getUniqueId()); if (connection == null || !connection.isConnected() || connection.isDisabled()) continue; Group group = connection.getGroup(); @@ -109,14 +111,17 @@ final class BridgeVoicechat implements VoicechatPlugin { byGroup.computeIfAbsent(group.getId(), k -> new ArrayList<>()).add(connection); } - Map counts = new HashMap<>(); + Map> roster = new HashMap<>(); for (CrosstalkRoom room : rooms.values()) { List members = byGroup.getOrDefault(room.groupId(), List.of()); room.setMembers(members); room.dropIdleSpeakers(); - counts.put(room.key(), members.size()); + roster.put(room.key(), members.stream().map(c -> { + UUID id = c.getPlayer().getUuid(); + return new Message.Member(id, names.getOrDefault(id, id.toString())); + }).toList()); } - link.sendHello(counts); + link.sendHello(roster); } /** Netzwerk-Thread der Bridge-Verbindung. */ @@ -127,6 +132,20 @@ final class BridgeVoicechat implements VoicechatPlugin { CrosstalkRoom room = rooms.get(audio.room()); if (room != null) room.play(audio.source(), audio.opus()); } + case Message.Notice notice -> Bukkit.getScheduler().runTask(plugin, () -> { + CrosstalkRoom room = rooms.get(notice.room()); + if (room == null) return; + Component text = Component.text("[" + room.name() + "] ", NamedTextColor.GOLD) + .append(Component.text(notice.text(), NamedTextColor.GRAY)); + room.memberPlayers().forEach(player -> player.sendMessage(text)); + }); + case Message.Speakers speakers -> Bukkit.getScheduler().runTask(plugin, () -> { + CrosstalkRoom room = rooms.get(speakers.room()); + if (room == null || speakers.names().isEmpty() || !plugin.getConfig().getBoolean("actionbar", true)) return; + Component text = Component.text("Spricht: ", NamedTextColor.GRAY) + .append(Component.text(String.join(", ", speakers.names()), NamedTextColor.GOLD)); + room.memberPlayers().stream().filter(ActionbarCommand::enabled).forEach(player -> player.sendActionBar(text)); + }); case Message.Hello ignored -> {} } } diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/CrosstalkRoom.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/CrosstalkRoom.java index 68c84e2..88e827f 100644 --- a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/CrosstalkRoom.java +++ b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/CrosstalkRoom.java @@ -5,9 +5,13 @@ import de.maxhenkel.voicechat.api.VoicechatServerApi; import de.maxhenkel.voicechat.api.audiochannel.StaticAudioChannel; import eu.mhsl.voicebridge.protocol.Message; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; /** @@ -46,6 +50,11 @@ final class CrosstalkRoom { return current != null && current.active(); } + /** Main-Thread: die Spieler in der Gruppe. */ + synchronized List memberPlayers() { + return members.stream().map(c -> Bukkit.getPlayer(c.getPlayer().getUuid())).filter(Objects::nonNull).toList(); + } + synchronized void setMembers(List members) { if (sameMembers(this.members, members)) return; this.members = List.copyOf(members); diff --git a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoiceBridgePlugin.java b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoiceBridgePlugin.java index 373280f..3d6f3a3 100644 --- a/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoiceBridgePlugin.java +++ b/plugin/src/main/java/eu/mhsl/voicebridge/plugin/VoiceBridgePlugin.java @@ -22,6 +22,9 @@ public final class VoiceBridgePlugin extends JavaPlugin { InetSocketAddress bridge = new InetSocketAddress( getConfig().getString("bridge.host", "127.0.0.1"), getConfig().getInt("bridge.port", 24470)); + ActionbarCommand.init(this); + var command = getCommand("crosstalk"); + if (command != null) command.setExecutor(new ActionbarCommand()); voicechat = new BridgeVoicechat(this, bridge); service.registerPlugin(voicechat); } diff --git a/plugin/src/main/resources/config.yml b/plugin/src/main/resources/config.yml index bd8de9d..8651d12 100644 --- a/plugin/src/main/resources/config.yml +++ b/plugin/src/main/resources/config.yml @@ -2,3 +2,7 @@ bridge: host: 127.0.0.1 port: 24470 + +# Actionbar "Spricht: …" mit den gerade sprechenden Leuten aus Discord/TeamSpeak. +# Jeder Spieler kann sie zusätzlich mit /crosstalk actionbar für sich aus- und einschalten. +actionbar: true diff --git a/plugin/src/main/resources/plugin.yml b/plugin/src/main/resources/plugin.yml index a9c39dd..4b493f4 100644 --- a/plugin/src/main/resources/plugin.yml +++ b/plugin/src/main/resources/plugin.yml @@ -6,3 +6,12 @@ depend: [ voicechat ] authors: [ MineTec ] description: Verbindet Simple-Voice-Chat-Gruppen mit Discord und TeamSpeak website: mhsl.eu +commands: + crosstalk: + description: Einstellungen für die Crosstalk-Gruppen + usage: "/crosstalk actionbar" + permission: voicebridge.crosstalk +permissions: + voicebridge.crosstalk: + description: /crosstalk benutzen + default: true diff --git a/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Message.java b/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Message.java index 2e37add..8e50605 100644 --- a/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Message.java +++ b/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Message.java @@ -10,7 +10,9 @@ import java.util.UUID; public sealed interface Message { /** Plugin → Bridge, jede Sekunde: Herzschlag und verbundene Spieler pro Raum. */ - record Hello(int version, Map members) implements Message {} + record Hello(int version, Map> members) implements Message {} + + record Member(UUID id, String name) {} /** Bridge → Plugin, Antwort auf jedes Hello: die Räume und wer auf den anderen Seiten ist. */ record Rooms(List rooms) implements Message {} @@ -22,4 +24,10 @@ public sealed interface Message { * die Spieler-UUID, beim Bridge → Plugin eine feste UUID pro externem Sprecher. */ record Audio(String room, UUID source, byte[] opus) implements Message {} + + /** Bridge → Plugin: Chat-Hinweis an alle in der Gruppe, z. B. wer dazugekommen ist. */ + record Notice(String room, String text) implements Message {} + + /** Bridge → Plugin: wer aus Discord/TeamSpeak gerade spricht (für die Actionbar). */ + record Speakers(String room, List names) implements Message {} } diff --git a/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Protocol.java b/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Protocol.java index 2414ccf..26a1a05 100644 --- a/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Protocol.java +++ b/protocol/src/main/java/eu/mhsl/voicebridge/protocol/Protocol.java @@ -14,13 +14,15 @@ import java.util.UUID; public final class Protocol { /** Bei inkompatiblen Änderungen am Format hochzählen. */ - public static final int VERSION = 1; + public static final int VERSION = 2; public static final int DEFAULT_PORT = 24470; public static final int MAX_DATAGRAM = 2048; private static final byte HELLO = 1; private static final byte ROOMS = 2; private static final byte AUDIO = 3; + private static final byte NOTICE = 4; + private static final byte SPEAKERS = 5; private Protocol() {} @@ -32,9 +34,13 @@ public final class Protocol { out.writeByte(HELLO); out.writeInt(hello.version()); out.writeShort(hello.members().size()); - for (Map.Entry e : hello.members().entrySet()) { + for (Map.Entry> e : hello.members().entrySet()) { out.writeUTF(e.getKey()); - out.writeShort(e.getValue()); + out.writeShort(e.getValue().size()); + for (Message.Member member : e.getValue()) { + writeUuid(out, member.id()); + out.writeUTF(member.name()); + } } } case Message.Rooms rooms -> { @@ -55,6 +61,17 @@ public final class Protocol { writeUuid(out, audio.source()); out.write(audio.opus()); } + case Message.Notice notice -> { + out.writeByte(NOTICE); + out.writeUTF(notice.room()); + out.writeUTF(notice.text()); + } + case Message.Speakers speakers -> { + out.writeByte(SPEAKERS); + out.writeUTF(speakers.room()); + out.writeShort(speakers.names().size()); + for (String name : speakers.names()) out.writeUTF(name); + } } } catch (IOException e) { throw new UncheckedIOException(e); @@ -69,8 +86,14 @@ public final class Protocol { case HELLO -> { int version = in.readInt(); int count = in.readUnsignedShort(); - Map members = new LinkedHashMap<>(); - for (int i = 0; i < count; i++) members.put(in.readUTF(), in.readUnsignedShort()); + Map> members = new LinkedHashMap<>(); + for (int i = 0; i < count; i++) { + String room = in.readUTF(); + int size = in.readUnsignedShort(); + List list = new ArrayList<>(size); + for (int j = 0; j < size; j++) list.add(new Message.Member(readUuid(in), in.readUTF())); + members.put(room, list); + } yield new Message.Hello(version, members); } case ROOMS -> { @@ -83,6 +106,14 @@ public final class Protocol { yield new Message.Rooms(rooms); } case AUDIO -> new Message.Audio(in.readUTF(), readUuid(in), in.readAllBytes()); + case NOTICE -> new Message.Notice(in.readUTF(), in.readUTF()); + case SPEAKERS -> { + String room = in.readUTF(); + int size = in.readUnsignedShort(); + List names = new ArrayList<>(size); + for (int i = 0; i < size; i++) names.add(in.readUTF()); + yield new Message.Speakers(room, names); + } default -> throw new IOException("Unbekannter Nachrichtentyp " + type); }; } diff --git a/protocol/src/test/java/eu/mhsl/voicebridge/protocol/ProtocolTest.java b/protocol/src/test/java/eu/mhsl/voicebridge/protocol/ProtocolTest.java index 005a4ef..f1ed722 100644 --- a/protocol/src/test/java/eu/mhsl/voicebridge/protocol/ProtocolTest.java +++ b/protocol/src/test/java/eu/mhsl/voicebridge/protocol/ProtocolTest.java @@ -18,7 +18,8 @@ class ProtocolTest { @Test void hello() throws IOException { - Message.Hello hello = new Message.Hello(Protocol.VERSION, Map.of("crosstalk-1", 3)); + Message.Hello hello = new Message.Hello(Protocol.VERSION, + Map.of("crosstalk-1", List.of(new Message.Member(UUID.randomUUID(), "Steve")))); assertEquals(hello, roundTrip(hello)); } @@ -39,6 +40,14 @@ class ProtocolTest { assertArrayEquals(opus, audio.opus()); } + @Test + void noticeAndSpeakers() throws IOException { + Message.Notice notice = new Message.Notice("crosstalk-1", "Anna (Discord) ist dazugekommen."); + assertEquals(notice, roundTrip(notice)); + Message.Speakers speakers = new Message.Speakers("crosstalk-1", List.of("Anna (Discord)", "Bob (TeamSpeak)")); + assertEquals(speakers, roundTrip(speakers)); + } + @Test void unknownType() { assertThrows(IOException.class, () -> Protocol.decode(new byte[]{42}, 0, 1));