Voice chat

This commit is contained in:
ayunami2000 2022-07-22 01:11:49 -04:00
parent b5cf14b204
commit 4f3e6e1d58
7 changed files with 316 additions and 3 deletions

View File

@ -0,0 +1,71 @@
package me.ayunami2000.ayungee;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
// note that there's a few things not implemented, but I don't care.
public class ExpiringSet<T> extends HashSet<T> {
private final long expiration;
private final ExpiringEvent<T> event;
private final Map<T, Long> timestamps = new HashMap<>();
public ExpiringSet(long expiration) {
this.expiration = expiration;
this.event = null;
}
public ExpiringSet(long expiration, ExpiringEvent<T> event) {
this.expiration = expiration;
this.event = event;
}
public interface ExpiringEvent<T> {
void onExpiration(T item);
}
public void checkForExpirations() {
Iterator<T> iterator = this.timestamps.keySet().iterator();
long now = System.currentTimeMillis();
while (iterator.hasNext()) {
T element = iterator.next();
if (super.contains(element)) {
if (this.timestamps.get(element) + this.expiration < now) {
if (this.event != null) this.event.onExpiration(element);
iterator.remove();
super.remove(element);
}
} else {
iterator.remove();
super.remove(element);
}
}
}
public boolean add(T o) {
checkForExpirations();
boolean success = super.add(o);
if (success) timestamps.put(o, System.currentTimeMillis());
return success;
}
public boolean remove(Object o) {
checkForExpirations();
boolean success = super.remove(o);
if (success) timestamps.remove(o);
return success;
}
public void clear() {
this.timestamps.clear();
super.clear();
}
public boolean contains(Object o) {
checkForExpirations();
return super.contains(o);
}
}

View File

@ -23,9 +23,11 @@ public class Main {
public static byte[] serverIcon = null;
public static boolean forwarded = false;
public static boolean serverCmd = true;
public static boolean voiceEnabled = true;
public static List<String> voiceICE = new ArrayList<>();
public static boolean useAuth = false;
public static int authIpLimit = -1;
@ -117,6 +119,21 @@ public class Main {
}
}
voiceICE.add("stun:openrelay.metered.ca:80");
voiceICE.add("turn:openrelay.metered.ca:80;openrelayproject;openrelayproject");
voiceICE.add("turn:openrelay.metered.ca:443;openrelayproject;openrelayproject");
voiceICE.add("turn:openrelay.metered.ca:443?transport=tcp;openrelayproject;openrelayproject");
Map<String, Object> configVoice = new LinkedHashMap<>();
configVoice.put("enabled", true);
configVoice.put("ice", voiceICE);
configVoice = (LinkedHashMap<String, Object>) config.getOrDefault("voice", configVoice);
voiceEnabled = (boolean) configVoice.getOrDefault("enabled", true);
voiceICE = (List<String>) configVoice.getOrDefault("ice", voiceICE);
Map<String, Object> configAuth = new LinkedHashMap<>();
configAuth.put("enabled", false);
configAuth.put("ip_limit", -1);

View File

@ -30,7 +30,7 @@ public class PluginMessages {
}
public static boolean fromClient(Client client, String name, byte[] data) {
return Skins.setSkin(client.username, client.conn, name, data);
return Skins.setSkin(client.username, client.conn, name, data) || Voice.handleVoice(client.username, client.conn, name, data);
}
public static boolean serverPluginMessage(Client client, byte[] packet) {

View File

@ -0,0 +1,209 @@
package me.ayunami2000.ayungee;
import org.java_websocket.WebSocket;
import java.io.*;
import java.util.*;
public class Voice {
private static final Map<String, WebSocket> voicePlayers = new HashMap<>();
private static final Map<String, ExpiringSet<String>> voiceRequests = new HashMap<>();
private static final Set<String[]> voicePairs = new HashSet<>();
private static final int VOICE_SIGNAL_ALLOWED = 0;
private static final int VOICE_SIGNAL_REQUEST = 0;
private static final int VOICE_SIGNAL_CONNECT = 1;
private static final int VOICE_SIGNAL_DISCONNECT = 2;
private static final int VOICE_SIGNAL_ICE = 3;
private static final int VOICE_SIGNAL_DESC = 4;
private static final int VOICE_SIGNAL_GLOBAL = 5;
public static boolean handleVoice(String user, WebSocket connection, String tag, byte[] msg) {
synchronized (voicePlayers) {
if (!Main.voiceEnabled) return false;
if (!("EAG|Voice".equals(tag))) return false;
try {
if (msg.length == 0) return true;
DataInputStream streamIn = new DataInputStream(new ByteArrayInputStream(msg));
int sig = streamIn.read();
switch (sig) {
case VOICE_SIGNAL_CONNECT:
if (voicePlayers.containsKey(user)) return true; // user is already using voice chat
// send out packet for player joined voice
// notice: everyone on the server can see this packet!! however, it doesn't do anything but let clients know that the player has turned on voice chat
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
dos.write(VOICE_SIGNAL_CONNECT);
dos.writeUTF(user);
byte[] out = baos.toByteArray();
for (WebSocket conn : voicePlayers.values()) sendVoicePacket(conn, out);
voicePlayers.put(user, connection);
for (String username : voicePlayers.keySet()) sendVoicePlayers(username);
break;
case VOICE_SIGNAL_DISCONNECT:
if (!voicePlayers.containsKey(user)) return true; // user is not using voice chat
try {
String user2 = streamIn.readUTF();
if (!voicePlayers.containsKey(user2)) return true;
if (voicePairs.removeIf(pair -> (pair[0].equals(user) && pair[1].equals(user2)) || (pair[0].equals(user2) && pair[1].equals(user)))) {
ByteArrayOutputStream baos2 = new ByteArrayOutputStream();
DataOutputStream dos2 = new DataOutputStream(baos2);
dos2.write(VOICE_SIGNAL_DISCONNECT);
dos2.writeUTF(user);
sendVoicePacket(voicePlayers.get(user2), baos2.toByteArray());
baos2 = new ByteArrayOutputStream();
dos2 = new DataOutputStream(baos2);
dos2.write(VOICE_SIGNAL_DISCONNECT);
dos2.writeUTF(user2);
sendVoicePacket(connection, baos2.toByteArray());
}
} catch (EOFException e) {
removeUser(user);
}
break;
case VOICE_SIGNAL_REQUEST:
if (!voicePlayers.containsKey(user)) return true; // user is not using voice chat
String targetUser = streamIn.readUTF();
if (user.equals(targetUser)) return true; // prevent duplicates
if (checkVoicePair(user, targetUser)) return true; // already paired
if (!voicePlayers.containsKey(targetUser)) return true; // target user is not using voice chat
if (!voiceRequests.containsKey(user)) voiceRequests.put(user, new ExpiringSet<>(2000));
if (voiceRequests.get(user).contains(targetUser)) return true;
voiceRequests.get(user).add(targetUser);
// check if other has requested earlier
if (voiceRequests.containsKey(targetUser) && voiceRequests.get(targetUser).contains(user)) {
if (voiceRequests.containsKey(targetUser)) {
voiceRequests.get(targetUser).remove(user);
if (voiceRequests.get(targetUser).isEmpty()) voiceRequests.remove(targetUser);
}
if (voiceRequests.containsKey(user)) {
voiceRequests.get(user).remove(targetUser);
if (voiceRequests.get(user).isEmpty()) voiceRequests.remove(user);
}
// send each other add data
voicePairs.add(new String[]{user, targetUser});
ByteArrayOutputStream baos2 = new ByteArrayOutputStream();
DataOutputStream dos2 = new DataOutputStream(baos2);
dos2.write(VOICE_SIGNAL_CONNECT);
dos2.writeUTF(user);
dos2.writeBoolean(false);
sendVoicePacket(voicePlayers.get(targetUser), baos2.toByteArray());
baos2 = new ByteArrayOutputStream();
dos2 = new DataOutputStream(baos2);
dos2.write(VOICE_SIGNAL_CONNECT);
dos2.writeUTF(targetUser);
dos2.writeBoolean(true);
sendVoicePacket(connection, baos2.toByteArray());
}
break;
case VOICE_SIGNAL_ICE:
case VOICE_SIGNAL_DESC:
if (!voicePlayers.containsKey(user)) return true; // user is not using voice chat
String targetUser2 = streamIn.readUTF();
if (checkVoicePair(user, targetUser2)) {
String data = streamIn.readUTF();
ByteArrayOutputStream baos2 = new ByteArrayOutputStream();
DataOutputStream dos2 = new DataOutputStream(baos2);
dos2.write(sig);
dos2.writeUTF(user);
dos2.writeUTF(data);
sendVoicePacket(voicePlayers.get(targetUser2), baos2.toByteArray());
}
break;
default:
break;
}
} catch (Throwable t) {
// hacker
// t.printStackTrace(); // todo: remove in production
removeUser(user);
}
}
return true;
}
public static void onLogin(String username, WebSocket conn) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
dos.write(VOICE_SIGNAL_ALLOWED);
dos.writeBoolean(Main.voiceEnabled);
dos.write(Main.voiceICE.size());
for(String str : Main.voiceICE) {
dos.writeUTF(str);
}
sendVoicePacket(conn, baos.toByteArray());
sendVoicePlayers(username);
} catch (IOException ignored) { }
}
public static void onQuit(String username) {
removeUser(username);
}
private static void sendVoicePlayers(String name) {
synchronized (voicePlayers) {
if (!Main.voiceEnabled) return;
if (!voicePlayers.containsKey(name)) return;
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
dos.write(VOICE_SIGNAL_GLOBAL);
Set<String> mostlyGlobalPlayers = new HashSet<>();
for (String username : voicePlayers.keySet()) {
if (username.equals(name)) continue;
if (voicePairs.stream().anyMatch(pair -> (pair[0].equals(name) && pair[1].equals(username)) || (pair[0].equals(username) && pair[1].equals(name))))
continue;
mostlyGlobalPlayers.add(username);
}
if (mostlyGlobalPlayers.size() > 0) {
dos.writeInt(mostlyGlobalPlayers.size());
for (String username : mostlyGlobalPlayers) dos.writeUTF(username);
sendVoicePacket(voicePlayers.get(name), baos.toByteArray());
}
} catch (IOException ignored) {
}
}
}
private static void removeUser(String name) {
synchronized (voicePlayers) {
voicePlayers.remove(name);
for (String username : voicePlayers.keySet()) {
if (!name.equals(username)) sendVoicePlayers(username);
}
for (String[] voicePair : voicePairs) {
String target = null;
if (voicePair[0].equals(name)) {
target = voicePair[1];
} else if (voicePair[1].equals(name)) {
target = voicePair[0];
}
if (target != null && voicePlayers.containsKey(target)) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
dos.write(VOICE_SIGNAL_DISCONNECT);
dos.writeUTF(name);
sendVoicePacket(voicePlayers.get(target), baos.toByteArray());
} catch (IOException ignored) {
}
}
}
voicePairs.removeIf(pair -> pair[0].equals(name) || pair[1].equals(name));
}
}
private static boolean checkVoicePair(String user1, String user2) {
return voicePairs.stream().anyMatch(pair -> (pair[0].equals(user1) && pair[1].equals(user2)) || (pair[0].equals(user2) && pair[1].equals(user1)));
}
private static void sendVoicePacket(WebSocket conn, byte[] msg) {
byte[] packetPrefix = new byte[] { (byte) 250, 0, 9, 0, 69, 0, 65, 0, 71, 0, 124, 0, 86, 0, 111, 0, 105, 0, 99, 0, 101, (byte) ((msg.length >>> 8) & 0xFF), (byte) (msg.length & 0xFF) };
byte[] fullPacket = new byte[packetPrefix.length + msg.length];
System.arraycopy(packetPrefix, 0, fullPacket, 0, packetPrefix.length);
System.arraycopy(msg, 0, fullPacket, packetPrefix.length, msg.length);
conn.send(fullPacket);
}
}

View File

@ -66,6 +66,7 @@ public class WebSocketProxy extends WebSocketServer {
if (selfClient != null) {
Main.printMsg("Player " + selfClient + " left!");
Skins.removeSkin(selfClient.username);
Voice.onQuit(selfClient.username);
if (selfClient.socket.isClosed()) {
try {
selfClient.socket.close();
@ -169,7 +170,10 @@ public class WebSocketProxy extends WebSocketServer {
if (!selfClient.authed && data[0] == 13) selfClient.positionPacket = data;
boolean loginPacket = data[0] == 1;
if (loginPacket && !selfClient.hasLoginHappened) selfClient.hasLoginHappened = true;
if (selfClient.firstTime && loginPacket) selfClient.clientEntityId = selfClient.serverEntityId = EntityMap.readInt(data, 1);
if (selfClient.firstTime && loginPacket) {
selfClient.clientEntityId = selfClient.serverEntityId = EntityMap.readInt(data, 1);
Voice.onLogin(username, conn);
}
if (!selfClient.firstTime && loginPacket) {
selfClient.serverEntityId = EntityMap.readInt(data, 1);
// assume server is giving valid data; we don't have to validate it because it isn't a potentially malicious client

View File

@ -13,6 +13,18 @@ origin_blacklist: "https://g.eags.us/eaglercraft/origin_blacklist.txt"
origins: []
# enable /server command?
server_cmd: true
# voice chat
voice:
# enable voice chat
enabled: true
# voice chat ICE servers
# format is like <server> OR <server>;<username>;<password>
# chances are you won't have to change these
ice:
- "stun:openrelay.metered.ca:80"
- "turn:openrelay.metered.ca:80;openrelayproject;openrelayproject"
- "turn:openrelay.metered.ca:443;openrelayproject;openrelayproject"
- "turn:openrelay.metered.ca:443?transport=tcp;openrelayproject;openrelayproject"
# authentication info
auth:
# use auth