activeSessions = Collections.synchronizedMap(new HashMap<>());
+
+ /**
+ * The unix time of when the last time stats were logged.
+ */
+ private long lastStat;
+
+ protected WebSocket(@NonNull DiscordService discordService) {
+ this.discordService = discordService;
+
+ // Schedule a task to send statuses to listening clients
+ new Timer().scheduleAtFixedRate(new TimerTask() {
+ @Override @SneakyThrows
+ public void run() {
+ if (!activeSessions.isEmpty() && (System.currentTimeMillis() - lastStat) >= TimeUnit.SECONDS.toMillis(30L)) {
+ lastStat = System.currentTimeMillis();
+ log.info("Active Sessions: {}", activeSessions.size());
+ }
+ for (WebSocketClient client : activeSessions.values()) {
+ // Disconnect users that have not been active for 15 seconds
+ if (client.getListeningTo() == null && ((System.currentTimeMillis() - client.getConnected()) >= TimeUnit.SECONDS.toMillis(15L))) {
+ client.getSession().close(CloseStatus.NOT_ACCEPTABLE.withReason("Client is inactive"));
+ continue;
+ }
+ if (client.getListeningTo() == null) {
+ continue;
+ }
+ // Notify the listening client of the user's status if it has changed
+ try {
+ DiscordUser user = discordService.getUserBySnowflake(client.getListeningTo()).getUser();
+ if (!user.equals(client.getLastUser())) {
+ client.setLastUser(user);
+ dispatch(client.getSession(), new UserStatusPacket(user));
+ }
+ } catch (BadRequestException | ServiceUnavailableException | ResourceNotFoundException ex) {
+ client.setListeningTo(null);
+ dispatch(client.getSession(), new ErrorMessagePacket(ex.getLocalizedMessage()));
+ }
+ }
+ }
+ }, 1000L, 1000L);
+ }
+
+ /**
+ * Received a new session, store it.
+ *
+ * @param session the received session
+ */
+ @Override
+ public final void afterConnectionEstablished(@NonNull WebSocketSession session) {
+ String sessionId = session.getId();
+ log.info("New session established: {}", sessionId);
+ activeSessions.put(sessionId, new WebSocketClient(session));
+ }
+
+ /**
+ * Handle receiving a string
+ * message from a session.
+ *
+ * @param session the session
+ * @param message the message
+ */
+ @Override @SneakyThrows
+ protected final void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) {
+ String sessionId = session.getId();
+ WebSocketClient client = activeSessions.get(sessionId);
+ if (client == null) { // No active client for the session
+ session.close(CloseStatus.NOT_ACCEPTABLE.withReason("No active session"));
+ return;
+ }
+ try {
+ Packet received = AppConfig.GSON.fromJson(message.getPayload(), Packet.class); // Parse the received packet
+ int opCode = received.getOpCode();
+ Class extends Packet> packetClass = PacketRegistry.get(opCode);
+
+ // Received packet is not valid, ignore it
+ if (packetClass == null) {
+ return;
+ }
+ Packet packet = AppConfig.GSON.fromJson(message.getPayload(), packetClass);
+ log.info("Received packet (SID: {}, Op: {}): {}", sessionId, opCode, packetClass.getName());
+
+ // Handle the packet
+ if (packet instanceof ListenToUserPacket listenToUserPacket) {
+ client.setListeningTo(listenToUserPacket.getSnowflake());
+ log.info("Session {} is listening to user updates for {}", sessionId, client.getListeningTo());
+ }
+ } catch (JsonSyntaxException ex) { // The syntax provided is invalid, close the session
+ session.close(CloseStatus.NOT_ACCEPTABLE.withReason("Invalid payload"));
+ log.warn("Rejected invalid payload: {}", sessionId);
+ }
+ }
+
+ /**
+ * A session has closed, remove it.
+ *
+ * @param session the closed session
+ * @param status the close status
+ */
+ @Override
+ public final void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) {
+ String sessionId = session.getId();
+ activeSessions.remove(sessionId);
+ log.info("Session closed ({}): {}", status, sessionId);
+ }
+
+ /**
+ * Send a packet to the given session.
+ *
+ * @param session the session to send to
+ * @param packet the packet to send
+ */
+ @SneakyThrows
+ private void dispatch(@NonNull WebSocketSession session, @NonNull Packet packet) {
+ session.sendMessage(new TextMessage(AppConfig.GSON.toJson(packet)));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/me/braydon/tether/service/websocket/WebSocketClient.java b/src/main/java/me/braydon/tether/service/websocket/WebSocketClient.java
new file mode 100644
index 0000000..f8147ab
--- /dev/null
+++ b/src/main/java/me/braydon/tether/service/websocket/WebSocketClient.java
@@ -0,0 +1,47 @@
+package me.braydon.tether.service.websocket;
+
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.Setter;
+import me.braydon.tether.model.DiscordUser;
+import org.springframework.web.socket.WebSocketSession;
+
+/**
+ * A client currently connected
+ * to the {@link WebSocket}.
+ *
+ * @author Braydon
+ */
+@Setter @Getter
+public class WebSocketClient {
+ /**
+ * The session this client is for.
+ */
+ @NonNull private final WebSocketSession session;
+
+ /**
+ * The snowflake of the user this client
+ * is listening to for updates, if any.
+ */
+ private Long listeningTo;
+
+ /**
+ * The last user this client
+ * has been sent a status for.
+ *
+ * This is kept so we only notify
+ * the client if the user has changed.
+ *
+ */
+ private DiscordUser lastUser;
+
+ /**
+ * The unix time this client connected.
+ */
+ private final long connected;
+
+ protected WebSocketClient(@NonNull WebSocketSession session) {
+ this.session = session;
+ connected = System.currentTimeMillis();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/me/braydon/tether/service/websocket/WebSocketService.java b/src/main/java/me/braydon/tether/service/websocket/WebSocketService.java
new file mode 100644
index 0000000..4188876
--- /dev/null
+++ b/src/main/java/me/braydon/tether/service/websocket/WebSocketService.java
@@ -0,0 +1,34 @@
+package me.braydon.tether.service.websocket;
+
+import lombok.NonNull;
+import lombok.extern.log4j.Log4j2;
+import me.braydon.tether.service.DiscordService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.web.socket.config.annotation.EnableWebSocket;
+import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+
+/**
+ * @author Braydon
+ */
+@Service @EnableWebSocket @Log4j2(topic = "WebSockets")
+public class WebSocketService implements WebSocketConfigurer {
+ private static final String WS_PATH = "/gateway";
+
+ /**
+ * The WebSocket to use.
+ */
+ @NonNull private final WebSocket webSocket;
+
+ @Autowired
+ public WebSocketService(@NonNull DiscordService discordService) {
+ webSocket = new WebSocket(discordService);
+ }
+
+ @Override
+ public void registerWebSocketHandlers(@NonNull WebSocketHandlerRegistry registry) {
+ registry.addHandler(webSocket, WS_PATH).setAllowedOrigins("*");
+ log.info("Added WebSocket on path {}", WS_PATH);
+ }
+}
\ No newline at end of file