/* * DiscordSRV - A Minecraft to Discord and back link plugin * Copyright (C) 2016-2020 Austin "Scarsz" Shapiro * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package github.scarsz.discordsrv.modules.voice; import github.scarsz.discordsrv.DiscordSRV; import github.scarsz.discordsrv.util.DiscordUtil; import github.scarsz.discordsrv.util.PlayerUtil; import lombok.Getter; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.events.guild.voice.GuildVoiceJoinEvent; import net.dv8tion.jda.api.events.guild.voice.GuildVoiceLeaveEvent; import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMoveEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.requests.restaction.PermissionOverrideAction; import org.apache.commons.lang3.StringUtils; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerTeleportEvent; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; public class VoiceModule extends ListenerAdapter implements Listener { public VoiceModule() { if (DiscordSRV.config().getBoolean("Voice enabled")) { DiscordSRV.getPlugin().getJda().addEventListener(this); Bukkit.getPluginManager().registerEvents(this, DiscordSRV.getPlugin()); Bukkit.getScheduler().runTaskLater(DiscordSRV.getPlugin(), () -> Bukkit.getScheduler().runTaskTimerAsynchronously( DiscordSRV.getPlugin(), this::tick, 0, DiscordSRV.config().getInt("Tick speed") ), 0 ); } Category category = DiscordSRV.getPlugin().getJda().getCategoryById(DiscordSRV.config().getString("Voice category")); if (category != null) { category.getVoiceChannels().stream() .filter(channel -> { try { //noinspection ResultOfMethodCallIgnored UUID.fromString(channel.getName()); return true; } catch (Exception e) { return false; } }) .forEach(channel -> channel.delete().reason("Orphan").queue()); } } private final ReentrantLock lock = new ReentrantLock(); private Set<Player> dirtyPlayers = new HashSet<>(); @Getter private final Set<Network> networks = ConcurrentHashMap.newKeySet(); private static final Set<String> mutedUsers = ConcurrentHashMap.newKeySet(); private void tick() { if (!lock.tryLock()) { DiscordSRV.debug("Skipping voice module tick, a tick is already in progress"); return; } try { if (getCategory() == null) { DiscordSRV.debug("Skipping voice module tick, category is null"); return; } if (getLobbyChannel() == null) { DiscordSRV.debug("Skipping voice module tick, lobby channel is null"); return; } // remove networks that have no voice channel networks.stream() .filter(network -> network.getChannel() == null) .forEach(Network::die); checkPermissions(); // getCategory().getVoiceChannels().stream() // .filter(channel -> { // try { // //noinspection ResultOfMethodCallIgnored // UUID.fromString(channel.getName()); // return true; // } catch (Exception e) { // return false; // } // }) // .filter(channel -> networks.stream().noneMatch(network -> network.getChannel().equals(channel))) // .forEach(channel -> { // DiscordSRV.debug("Deleting network " + channel + ", no members"); // channel.delete().reason("Orphan").queue(); // }); Set<Player> oldDirtyPlayers = dirtyPlayers; dirtyPlayers = new HashSet<>(); for (Player player : oldDirtyPlayers) { DiscordSRV.debug("Dirty: " + player.getName()); Member member = getMember(player); if (member == null || member.getVoiceState() == null || member.getVoiceState().getChannel() == null || member.getVoiceState().getChannel().getParent() == null || !member.getVoiceState().getChannel().getParent().getId().equals(getCategory().getId())) { DiscordSRV.debug("Player " + player.getName() + " isn't connected to voice or isn't in the voice category or the player doesn't have a linked account (" + member + ")"); continue; } // if player is in lobby, move them to the network that they might already be in networks.stream() .filter(network -> network.getPlayers().contains(player)) .forEach(network -> { if (!network.getChannel().getMembers().contains(member) && !member.getVoiceState().getChannel().equals(network.getChannel())) { DiscordSRV.debug("Player " + player.getName() + " isn't in the right network channel but they are in the category, connecting"); network.connect(player); } }); // add player to networks that they may have came into contact with networks.stream() .filter(network -> network.playerIsInConnectionRange(player)) .reduce((network1, network2) -> { if (network1.getPlayers().size() > network2.getPlayers().size()) { network1.engulf(network2); return network1; } else { network2.engulf(network1); return network2; } }) .filter(network -> !network.getPlayers().contains(player)) .ifPresent(network -> { DiscordSRV.debug(player.getName() + " has entered network " + network + "'s influence, connecting"); network.connect(player); }); // remove player from networks that they lost connection to networks.stream() .filter(network -> network.getPlayers().contains(player)) .filter(network -> !network.playerIsInRange(player)) .collect(Collectors.toSet()) // needed to prevent concurrent modifications .forEach(network -> { DiscordSRV.debug("Player " + player.getName() + " lost connection to " + network + ", disconnecting"); network.disconnect(player); }); // create networks if two players are within activation distance Set<Player> playersWithinRange = PlayerUtil.getOnlinePlayers().stream() .filter(p -> networks.stream().noneMatch(network -> network.getPlayers().contains(p))) .filter(p -> !p.equals(player)) .filter(p -> p.getWorld().getName().equals(player.getWorld().getName())) .filter(p -> p.getLocation().distance(player.getLocation()) < getStrength()) .filter(p -> { Member m = getMember(p); return m != null && m.getVoiceState() != null && m.getVoiceState().getChannel() != null && m.getVoiceState().getChannel().getParent() != null && m.getVoiceState().getChannel().getParent().getId().equals(getCategory().getId()); }) .collect(Collectors.toSet()); if (playersWithinRange.size() > 0) { if (getCategory().getChannels().size() == 50) { DiscordSRV.debug("Can't create new voice network because category " + getCategory().getName() + " is full of channels"); return; } try { Network network = Network.with(playersWithinRange); network.connect(player); networks.add(network); } catch (Exception e) { DiscordSRV.error("Failed to create new voice network: " + e.getMessage()); } } } } finally { lock.unlock(); } } public void shutdown() { this.networks.forEach(Network::die); this.networks.clear(); } private void checkPermissions() { checkCategoryPermissions(); checkLobbyPermissions(); networks.forEach(this::checkNetworkPermissions); } private void checkCategoryPermissions() { PermissionOverride override = getCategory().getPermissionOverride(getGuild().getPublicRole()); if (override == null) { getCategory().createPermissionOverride(getGuild().getPublicRole()) .setDeny(Permission.VOICE_CONNECT) .queue(null, (throwable) -> DiscordSRV.error("Failed to create permission override for category " + getCategory().getName() + ": " + throwable.getMessage()) ); } else { if (!override.getDenied().contains(Permission.VOICE_CONNECT)) { override.getManager().deny(Permission.VOICE_CONNECT).complete(); } } } private void checkLobbyPermissions() { PermissionOverride override = getLobbyChannel().getPermissionOverride(getGuild().getPublicRole()); if (override == null) { getLobbyChannel().createPermissionOverride(getGuild().getPublicRole()) .setAllow(Permission.VOICE_CONNECT) .setDeny(Permission.VOICE_SPEAK) .queue(null, (throwable) -> DiscordSRV.error("Failed to create permission override for lobby channel " + getLobbyChannel().getName() + ": " + throwable.getMessage()) ); } } @SuppressWarnings("ResultOfMethodCallIgnored") private void checkNetworkPermissions(Network network) { PermissionOverride override = network.getChannel().getPermissionOverride(getGuild().getPublicRole()); if (override == null) { PermissionOverrideAction action = network.getChannel().createPermissionOverride(getGuild().getPublicRole()); if (isVoiceActivationAllowed()) { action.setAllow(Permission.VOICE_SPEAK, Permission.VOICE_USE_VAD); action.setDeny(Permission.VOICE_CONNECT); } else { action.setAllow(Permission.VOICE_SPEAK); action.setDeny(Permission.VOICE_CONNECT, Permission.VOICE_USE_VAD); } action.queue(null, (throwable) -> DiscordSRV.error("Failed to create permission override for network " + network.getChannel().getName() + ": " + throwable.getMessage()) ); } else { List<Permission> allowed; List<Permission> denied; if (isVoiceActivationAllowed()) { allowed = Arrays.asList(Permission.VOICE_SPEAK, Permission.VOICE_USE_VAD); denied = Collections.singletonList(Permission.VOICE_CONNECT); } else { allowed = Collections.singletonList(Permission.VOICE_SPEAK); denied = Arrays.asList(Permission.VOICE_CONNECT, Permission.VOICE_USE_VAD); } boolean dirty = false; PermissionOverrideAction manager = override.getManager(); if (!override.getAllowed().containsAll(allowed)) { manager.grant(allowed); dirty = true; } if (!override.getDenied().containsAll(denied)) { manager.deny(denied); dirty = true; } if (dirty) manager.complete(); } } @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) public void onPlayerJoin(PlayerJoinEvent event) { markDirty(event.getPlayer()); } @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) public void onPlayerMove(PlayerMoveEvent event) { markDirty(event.getPlayer()); } @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) public void onPlayerTeleport(PlayerTeleportEvent event) { markDirty(event.getPlayer()); } @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) public void onPlayerQuit(PlayerQuitEvent event) { Bukkit.getScheduler().runTaskAsynchronously(DiscordSRV.getPlugin(), () -> { networks.stream() .filter(network -> network.getPlayers().contains(event.getPlayer())) .forEach(network -> network.disconnect(event.getPlayer())); }); } @Override public void onGuildVoiceJoin(GuildVoiceJoinEvent event) { checkMutedUser(event.getChannelJoined(), event.getMember()); if (!event.getChannelJoined().equals(getLobbyChannel())) return; UUID uuid = DiscordSRV.getPlugin().getAccountLinkManager().getUuid(event.getMember().getUser().getId()); if (uuid == null) return; OfflinePlayer player = Bukkit.getOfflinePlayer(uuid); if (player.isOnline()) markDirty(player.getPlayer()); } @Override public void onGuildVoiceMove(GuildVoiceMoveEvent event) { if (event.getChannelJoined().getParent() != null && !event.getChannelJoined().getParent().equals(getCategory()) && event.getChannelLeft().getParent() != null && event.getChannelLeft().getParent().equals(getCategory())) { UUID uuid = DiscordSRV.getPlugin().getAccountLinkManager().getUuid(event.getMember().getUser().getId()); if (uuid == null) return; OfflinePlayer player = Bukkit.getOfflinePlayer(uuid); if (player.isOnline()) { networks.stream() .filter(network -> network.getPlayers().contains(player.getPlayer())) .forEach(network -> network.disconnect(player.getPlayer())); } } checkMutedUser(event.getChannelJoined(), event.getMember()); } @Override public void onGuildVoiceLeave(GuildVoiceLeaveEvent event) { checkMutedUser(event.getChannelJoined(), event.getMember()); if (event.getChannelLeft().getParent() == null || !event.getChannelLeft().getParent().equals(getCategory())) return; UUID uuid = DiscordSRV.getPlugin().getAccountLinkManager().getUuid(event.getMember().getUser().getId()); if (uuid == null) return; OfflinePlayer player = Bukkit.getOfflinePlayer(uuid); if (player.isOnline()) { networks.stream() .filter(network -> network.getPlayers().contains(player.getPlayer())) .forEach(network -> network.disconnect(player.getPlayer())); } } private static void checkMutedUser(VoiceChannel channel, Member member) { if (channel == null || member.getVoiceState() == null) { return; } boolean isLobby = channel.getId().equals(getLobbyChannel().getId()); if (isLobby && !member.getVoiceState().isGuildMuted()) { PermissionOverride override = channel.getPermissionOverride(channel.getGuild().getPublicRole()); if (override != null && override.getDenied().contains(Permission.VOICE_SPEAK) && member.hasPermission(channel, Permission.VOICE_SPEAK, Permission.VOICE_MUTE_OTHERS) && channel.getGuild().getSelfMember().hasPermission(channel, Permission.VOICE_MUTE_OTHERS)) { member.mute(true).queue(); mutedUsers.add(member.getId()); } } else if (!isLobby) { if (mutedUsers.remove(member.getId())) { member.mute(false).queue(); } } } private void markDirty(Player player) { dirtyPlayers.add(player); } public static void moveToLobby(Member member) { try { VoiceChannel lobby = getLobbyChannel(); VoiceModule.getGuild().moveVoiceMember(member, lobby).complete(); checkMutedUser(lobby, member); } catch (Exception e) { DiscordSRV.error("Failed to move member " + member + " into voice channel " + VoiceModule.getLobbyChannel() + ": " + e.getMessage()); } } public static Set<String> getMutedUsers() { return mutedUsers; } public static VoiceModule get() { return DiscordSRV.getPlugin().getVoiceModule(); } public static Category getCategory() { if (DiscordUtil.getJda() == null) return null; String id = DiscordSRV.config().getString("Voice category"); if (StringUtils.isBlank(id)) return null; return DiscordUtil.getJda().getCategoryById(id); } public static VoiceChannel getLobbyChannel() { if (DiscordUtil.getJda() == null) return null; String id = DiscordSRV.config().getString("Lobby channel"); if (StringUtils.isBlank(id)) return null; return DiscordUtil.getJda().getVoiceChannelById(id); } public static Guild getGuild() { return getCategory().getGuild(); } public static double getStrength() { return DiscordSRV.config().getDouble("Network.Strength"); } public static double getFalloff() { return DiscordSRV.config().getDouble("Network.Falloff"); } public static boolean isVoiceActivationAllowed() { return DiscordSRV.config().getBoolean("Network.Allow voice activation detection"); } public static Member getMember(Player player) { String discordId = DiscordSRV.getPlugin().getAccountLinkManager().getDiscordId(player.getUniqueId()); return discordId != null ? getGuild().getMemberById(discordId) : null; } }