Compare commits
7
Commits
1a0bc902ac
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4623fbcc82 | ||
|
|
610ef2f327 | ||
|
|
bd6174dd23 | ||
|
|
8a61d35e63 | ||
|
|
2a65f98493 | ||
|
|
423d9c9043 | ||
|
|
7f94569575 |
@@ -62,6 +62,9 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/mail/**").authenticated()
|
.requestMatchers("/api/mail/**").authenticated()
|
||||||
.requestMatchers("/api/site/vote").authenticated()
|
.requestMatchers("/api/site/vote").authenticated()
|
||||||
.requestMatchers("/api/appeal").authenticated()
|
.requestMatchers("/api/appeal").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/chat/hide").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/chat/show").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/chat/visibility").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/chat/read/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
|
.requestMatchers("/api/chat/read/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
|
||||||
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
@@ -72,6 +75,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/login/userLogin/**").permitAll()
|
.requestMatchers("/api/login/userLogin/**").permitAll()
|
||||||
.requestMatchers("/api/site/chat/**").authenticated()
|
.requestMatchers("/api/site/chat/**").authenticated()
|
||||||
|
.requestMatchers("/api/site/chat/send-admin-chat/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
|
||||||
|
.requestMatchers("/api/site/chat/punish/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
|
||||||
.requestMatchers("/api/chat/server/send/**").permitAll()
|
.requestMatchers("/api/chat/server/send/**").permitAll()
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.alttd.altitudeweb.controllers.chat;
|
package com.alttd.altitudeweb.controllers.chat;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.ChatApi;
|
import com.alttd.altitudeweb.api.ChatApi;
|
||||||
|
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||||
import com.alttd.altitudeweb.model.ChatMessageDto;
|
import com.alttd.altitudeweb.model.ChatMessageDto;
|
||||||
|
import com.alttd.altitudeweb.model.PlayerListVisibilityDto;
|
||||||
import com.alttd.altitudeweb.model.ServerStateDto;
|
import com.alttd.altitudeweb.model.ServerStateDto;
|
||||||
import com.alttd.altitudeweb.services.chat.ChatService;
|
import com.alttd.altitudeweb.services.chat.ChatService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -11,6 +13,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@@ -18,6 +21,29 @@ import java.util.List;
|
|||||||
public class ChatController implements ChatApi {
|
public class ChatController implements ChatApi {
|
||||||
|
|
||||||
private final ChatService chatService;
|
private final ChatService chatService;
|
||||||
|
private final AuthenticatedUuid authenticatedUuid;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> hideUserFromPlayerList() {
|
||||||
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
|
chatService.hideUserFromPlayerList(uuid);
|
||||||
|
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> showUserInPlayerList() {
|
||||||
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
|
chatService.showUserInPlayerList(uuid);
|
||||||
|
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<PlayerListVisibilityDto> getPlayerListVisibility() {
|
||||||
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
|
PlayerListVisibilityDto visibility = new PlayerListVisibilityDto()
|
||||||
|
.hidden(chatService.isUserHiddenFromPlayerList(uuid));
|
||||||
|
return ResponseEntity.ok(visibility);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<Void> sendChatMessages(List<ChatMessageDto> chatMessageDtoList) {
|
public ResponseEntity<Void> sendChatMessages(List<ChatMessageDto> chatMessageDtoList) {
|
||||||
|
|||||||
+47
-11
@@ -2,9 +2,19 @@ package com.alttd.altitudeweb.controllers.chat;
|
|||||||
|
|
||||||
import com.alttd.altitudeweb.api.ServerMessageApi;
|
import com.alttd.altitudeweb.api.ServerMessageApi;
|
||||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||||
|
import com.alttd.altitudeweb.model.PartyMessageRequestDto;
|
||||||
|
import com.alttd.altitudeweb.model.PrivateMessageRequestDto;
|
||||||
|
import com.alttd.altitudeweb.model.PunishRequestDto;
|
||||||
import com.alttd.altitudeweb.model.ServerMessageRequestDto;
|
import com.alttd.altitudeweb.model.ServerMessageRequestDto;
|
||||||
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
|
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
|
||||||
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
|
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.PartyChatFromWeb;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.PrivateChatFromWeb;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.PunishFromWeb;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.mappers.ChatFromWebMapper;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.mappers.PartyChatFromWebMapper;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.mappers.PrivateChatFromWebMapper;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.mappers.PunishFromWebMapper;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -34,21 +44,47 @@ public class ServerMessageController implements ServerMessageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<Void> sendServerMessage(String server, ServerMessageRequestDto serverMessageRequestDto) {
|
public ResponseEntity<Void> punishPlayer(String server, PunishRequestDto punishRequestDto) throws Exception {
|
||||||
UUID authenticatedUserUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
validateUser(punishRequestDto.getExecutor());
|
||||||
|
return send(server, "web_punish", PunishFromWebMapper.fromDto(punishRequestDto));
|
||||||
if (!authenticatedUserUuid.equals(serverMessageRequestDto.getUuid())) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot send message as another user");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatFromWeb chatFromWeb = ChatFromWeb.builder()
|
@Override
|
||||||
.sender(serverMessageRequestDto.getUuid())
|
public ResponseEntity<Void> sendAdminChat(String server, ServerMessageRequestDto serverMessageRequestDto) {
|
||||||
.message(serverMessageRequestDto.getMessage())
|
validateUser(serverMessageRequestDto.getUuid());
|
||||||
.build();
|
return send(server, "web_ac_chat", ChatFromWebMapper.fromDto(serverMessageRequestDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> sendPartyMessage(PartyMessageRequestDto partyMessageRequestDto) {
|
||||||
|
validateUser(partyMessageRequestDto.getFrom());
|
||||||
|
return send("proxy", "web_party_chat", PartyChatFromWebMapper.fromDto(partyMessageRequestDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> sendPrivateMessage(PrivateMessageRequestDto privateMessageRequestDto) {
|
||||||
|
validateUser(privateMessageRequestDto.getFrom());
|
||||||
|
return send("proxy", "web_private_chat", PrivateChatFromWebMapper.fromDto(privateMessageRequestDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> sendServerMessage(String server, ServerMessageRequestDto serverMessageRequestDto) {
|
||||||
|
validateUser(serverMessageRequestDto.getUuid());
|
||||||
|
|
||||||
String json = toJson(chatFromWeb);
|
|
||||||
//TODO [Stijn] [2026-08-02]: Validate the user can send a message in this server (or do that in chat)
|
//TODO [Stijn] [2026-08-02]: Validate the user can send a message in this server (or do that in chat)
|
||||||
if (serverMessageService.sendMessage(server, "web_chat", json)) {
|
return send(server, "web_chat", ChatFromWebMapper.fromDto(serverMessageRequestDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateUser(UUID uuid) {
|
||||||
|
UUID authenticatedUserUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
|
if (!authenticatedUserUuid.equals(uuid)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot send message as another user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Void> send(String server, String channel, Object data) {
|
||||||
|
String json = toJson(data);
|
||||||
|
if (serverMessageService.sendMessage(server, channel, json)) {
|
||||||
return new ResponseEntity<>(HttpStatus.ACCEPTED);
|
return new ResponseEntity<>(HttpStatus.ACCEPTED);
|
||||||
} else {
|
} else {
|
||||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import com.alttd.altitudeweb.model.ServerStateDto;
|
|||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
|
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.mappers.PlayerListStateMapper;
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
import com.alttd.altitudeweb.setup.Connection;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -21,11 +23,14 @@ import org.springframework.boot.context.event.ApplicationReadyEvent;
|
|||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -35,8 +40,10 @@ public class ChatService {
|
|||||||
|
|
||||||
private static final Duration MAX_AGE = Duration.ofHours(1);
|
private static final Duration MAX_AGE = Duration.ofHours(1);
|
||||||
private final EventPublisher eventPublisher;
|
private final EventPublisher eventPublisher;
|
||||||
|
private final ServerMessageService serverMessageService;
|
||||||
private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>();
|
private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>();
|
||||||
private final Map<String, EventUser> eventUserMap = new HashMap<>();
|
private final Map<String, EventUser> eventUserMap = new ConcurrentHashMap<>();
|
||||||
|
private final Set<UUID> hiddenUsers = ConcurrentHashMap.newKeySet();
|
||||||
private final Map<String, ServerDto> serverStateCache = new HashMap<>();
|
private final Map<String, ServerDto> serverStateCache = new HashMap<>();
|
||||||
|
|
||||||
@Value("${chat.allowed-servers}")
|
@Value("${chat.allowed-servers}")
|
||||||
@@ -71,15 +78,18 @@ public class ChatService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public SseEmitter subscribe(EventUser eventUser, String json) {
|
public synchronized SseEmitter subscribe(EventUser eventUser, String json) {
|
||||||
String key = eventUser.uuid().toString();
|
String key = eventUser.uuid().toString();
|
||||||
eventUserMap.put(key, eventUser);
|
boolean newUser = eventUserMap.put(key, eventUser) == null;
|
||||||
SseEmitter emitter = eventPublisher.subscribe(key, json, this::handleSessionEnd);
|
SseEmitter emitter = eventPublisher.subscribe(key, json, this::handleSessionEnd);
|
||||||
|
if (newUser) {
|
||||||
|
sendPlayerStateToServers();
|
||||||
|
}
|
||||||
sendServerStateToUser(key, eventUser);
|
sendServerStateToUser(key, eventUser);
|
||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
|
private synchronized void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
|
||||||
EventUser eventUser = eventUserMap.get(key);
|
EventUser eventUser = eventUserMap.get(key);
|
||||||
if (eventUser == null) {
|
if (eventUser == null) {
|
||||||
log.error("Failed to find event user for key {}", key);
|
log.error("Failed to find event user for key {}", key);
|
||||||
@@ -91,6 +101,48 @@ public class ChatService {
|
|||||||
.session_end(sessionEnd)
|
.session_end(sessionEnd)
|
||||||
.build();
|
.build();
|
||||||
saveSession(chatSession);
|
saveSession(chatSession);
|
||||||
|
|
||||||
|
if (!eventPublisher.hasSubscribers(key) && eventUserMap.remove(key, eventUser)) {
|
||||||
|
hiddenUsers.remove(eventUser.uuid());
|
||||||
|
sendPlayerStateToServers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void hideUserFromPlayerList(UUID uuid) {
|
||||||
|
requireActiveHeadMod(uuid);
|
||||||
|
if (hiddenUsers.add(uuid)) {
|
||||||
|
sendPlayerStateToServers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void showUserInPlayerList(UUID uuid) {
|
||||||
|
requireActiveHeadMod(uuid);
|
||||||
|
if (hiddenUsers.remove(uuid)) {
|
||||||
|
sendPlayerStateToServers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean isUserHiddenFromPlayerList(UUID uuid) {
|
||||||
|
requireActiveHeadMod(uuid);
|
||||||
|
return hiddenUsers.contains(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireActiveHeadMod(UUID uuid) {
|
||||||
|
EventUser eventUser = eventUserMap.get(uuid.toString());
|
||||||
|
if (eventUser == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "No active chat session");
|
||||||
|
}
|
||||||
|
if (!eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only head moderators can change session visibility");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendPlayerStateToServers() {
|
||||||
|
ArrayList<UUID> activePlayers = eventUserMap.values().stream()
|
||||||
|
.map(EventUser::uuid)
|
||||||
|
.filter(uuid -> !hiddenUsers.contains(uuid))
|
||||||
|
.collect(Collectors.toCollection(ArrayList::new));
|
||||||
|
serverMessageService.sendMessageToAll("player_state", PlayerListStateMapper.fromList(activePlayers));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveSession(ChatSession chatSession) {
|
private void saveSession(ChatSession chatSession) {
|
||||||
|
|||||||
+26
-8
@@ -9,11 +9,11 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic SSE event publisher, keyed by an arbitrary "unique" string
|
* Generic SSE event publisher, keyed by an arbitrary "unique" string
|
||||||
@@ -28,6 +28,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
|
|||||||
public class EventPublisher {
|
public class EventPublisher {
|
||||||
|
|
||||||
private final Map<String, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
|
private final Map<String, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
|
||||||
|
private final Map<SseEmitter, Runnable> emitterCleanupMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoked once a given emitter for uniqueKey is cleaned up (completed,
|
* Invoked once a given emitter for uniqueKey is cleaned up (completed,
|
||||||
@@ -49,7 +50,13 @@ public class EventPublisher {
|
|||||||
Instant sessionStart = Instant.now();
|
Instant sessionStart = Instant.now();
|
||||||
log.info("Key {} subscribed, active emitters: {}", uniqueKey, countActive());
|
log.info("Key {} subscribed, active emitters: {}", uniqueKey, countActive());
|
||||||
|
|
||||||
Runnable cleanup = () -> handleCleanup(uniqueKey, emitter, sessionStart, cleanupCallback);
|
AtomicBoolean cleanedUp = new AtomicBoolean();
|
||||||
|
Runnable cleanup = () -> {
|
||||||
|
if (cleanedUp.compareAndSet(false, true)) {
|
||||||
|
handleCleanup(uniqueKey, emitter, sessionStart, cleanupCallback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emitterCleanupMap.put(emitter, cleanup);
|
||||||
|
|
||||||
// covers: clean disconnect (tab closed normally), server-side timeout, write failure
|
// covers: clean disconnect (tab closed normally), server-side timeout, write failure
|
||||||
emitter.onCompletion(cleanup);
|
emitter.onCompletion(cleanup);
|
||||||
@@ -74,15 +81,13 @@ public class EventPublisher {
|
|||||||
@Scheduled(fixedRate = 20_000)
|
@Scheduled(fixedRate = 20_000)
|
||||||
public void heartbeat() {
|
public void heartbeat() {
|
||||||
emitterMap.values().forEach(list -> {
|
emitterMap.values().forEach(list -> {
|
||||||
List<SseEmitter> dead = new ArrayList<>();
|
|
||||||
for (SseEmitter emitter : list) {
|
for (SseEmitter emitter : list) {
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().comment("heartbeat"));
|
emitter.send(SseEmitter.event().comment("heartbeat"));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
dead.add(emitter);
|
cleanup(emitter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
list.removeAll(dead);
|
|
||||||
});
|
});
|
||||||
emitterMap.entrySet().removeIf(e -> e.getValue().isEmpty());
|
emitterMap.entrySet().removeIf(e -> e.getValue().isEmpty());
|
||||||
}
|
}
|
||||||
@@ -90,26 +95,39 @@ public class EventPublisher {
|
|||||||
public void sendToUser(String uniqueKey, String eventName, String json) {
|
public void sendToUser(String uniqueKey, String eventName, String json) {
|
||||||
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
|
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
|
||||||
if (userEmitters == null) {
|
if (userEmitters == null) {
|
||||||
|
log.warn("No emitters found for unique key: {}", uniqueKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<SseEmitter> dead = new ArrayList<>();
|
|
||||||
for (SseEmitter emitter : userEmitters) {
|
for (SseEmitter emitter : userEmitters) {
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name(eventName).data(json));
|
emitter.send(SseEmitter.event().name(eventName).data(json));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
dead.add(emitter);
|
log.warn("Failed to send event to user: {}", uniqueKey, e);
|
||||||
|
cleanup(emitter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
userEmitters.removeAll(dead);
|
}
|
||||||
|
|
||||||
|
public boolean hasSubscribers(String uniqueKey) {
|
||||||
|
List<SseEmitter> emitters = emitterMap.get(uniqueKey);
|
||||||
|
return emitters != null && !emitters.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
private long countActive() {
|
private long countActive() {
|
||||||
return emitterMap.values().stream().mapToLong(List::size).sum();
|
return emitterMap.values().stream().mapToLong(List::size).sum();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void cleanup(SseEmitter emitter) {
|
||||||
|
Runnable cleanup = emitterCleanupMap.get(emitter);
|
||||||
|
if (cleanup != null) {
|
||||||
|
cleanup.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void handleCleanup(String uniqueKey, SseEmitter emitter, Instant sessionStart,
|
private void handleCleanup(String uniqueKey, SseEmitter emitter, Instant sessionStart,
|
||||||
SessionCleanupCallback cleanupCallback) {
|
SessionCleanupCallback cleanupCallback) {
|
||||||
|
emitterCleanupMap.remove(emitter);
|
||||||
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
|
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
|
||||||
if (userEmitters != null) {
|
if (userEmitters != null) {
|
||||||
userEmitters.remove(emitter);
|
userEmitters.remove(emitter);
|
||||||
|
|||||||
+28
-8
@@ -1,8 +1,6 @@
|
|||||||
package com.alttd.altitudeweb.services.chat.to_server;
|
package com.alttd.altitudeweb.services.chat.to_server;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
|
||||||
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -13,27 +11,49 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ServerMessageService {
|
public class ServerMessageService {
|
||||||
|
|
||||||
private final Set<String> servers = new HashSet<>();
|
private static final String PLAYER_STATE_CHANNEL = "player_state";
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
private final Set<String> servers = ConcurrentHashMap.newKeySet();
|
||||||
private final EventPublisher eventPublisher;
|
private final EventPublisher eventPublisher;
|
||||||
|
private volatile String latestPlayerState;
|
||||||
|
|
||||||
public SseEmitter subscribe(String server) {
|
public synchronized SseEmitter subscribe(String server) {
|
||||||
servers.add(server);
|
servers.add(server);
|
||||||
return eventPublisher.subscribe(server, this::handleSessionEnd);
|
log.info("Server {} subscribed", server);
|
||||||
|
SseEmitter emitter = eventPublisher.subscribe(server, this::handleSessionEnd);
|
||||||
|
if (latestPlayerState != null) {
|
||||||
|
eventPublisher.sendToUser(server, PLAYER_STATE_CHANNEL, latestPlayerState);
|
||||||
|
}
|
||||||
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
|
private synchronized void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
|
||||||
log.info("Server session ended for {}. Active from {} for {}", key, sessionStart, Duration.between(sessionStart, sessionEnd));
|
log.info("Server session ended for {}. Active from {} for {}", key, sessionStart, Duration.between(sessionStart, sessionEnd));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void sendMessageToAll(String channel, Object data) {
|
||||||
|
String json;
|
||||||
|
try {
|
||||||
|
json = OBJECT_MAPPER.writeValueAsString(data);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException("Failed to serialize server message", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PLAYER_STATE_CHANNEL.equals(channel)) {
|
||||||
|
latestPlayerState = json;
|
||||||
|
}
|
||||||
|
|
||||||
|
servers.forEach(server -> eventPublisher.sendToUser(server, channel, json));
|
||||||
|
}
|
||||||
|
|
||||||
public boolean sendMessage(String server, String channel, String json) {
|
public boolean sendMessage(String server, String channel, String json) {
|
||||||
if (!servers.contains(server)) {
|
if (!servers.contains(server)) {
|
||||||
log.warn("Server {} is not connected, not sending message", server);
|
log.warn("Server {} is not connected, not sending message", server);
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server.data;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
@Getter
|
||||||
|
public class PartyChatFromWeb {
|
||||||
|
|
||||||
|
private final UUID sender;
|
||||||
|
private final String message;
|
||||||
|
private final String partyId;
|
||||||
|
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server.data;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
@Getter
|
||||||
|
public class PlayerListState {
|
||||||
|
|
||||||
|
private List<UUID> activePlayers;
|
||||||
|
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server.data;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
@Getter
|
||||||
|
public class PrivateChatFromWeb {
|
||||||
|
|
||||||
|
private final UUID sender;
|
||||||
|
private final String message;
|
||||||
|
private final UUID recipient;
|
||||||
|
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server.data;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
@Getter
|
||||||
|
public class PunishFromWeb {
|
||||||
|
|
||||||
|
private final UUID executor;
|
||||||
|
private final UUID target;
|
||||||
|
private final String type;
|
||||||
|
private final String reason;
|
||||||
|
private final String time;
|
||||||
|
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server.mappers;
|
||||||
|
|
||||||
|
import com.alttd.altitudeweb.model.ServerMessageRequestDto;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
@UtilityClass
|
||||||
|
public class ChatFromWebMapper {
|
||||||
|
|
||||||
|
public static ChatFromWeb fromDto(ServerMessageRequestDto dto) {
|
||||||
|
return ChatFromWeb.builder()
|
||||||
|
.sender(dto.getUuid())
|
||||||
|
.message(dto.getMessage())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server.mappers;
|
||||||
|
|
||||||
|
import com.alttd.altitudeweb.model.PartyMessageRequestDto;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.PartyChatFromWeb;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
@UtilityClass
|
||||||
|
public class PartyChatFromWebMapper {
|
||||||
|
|
||||||
|
public static PartyChatFromWeb fromDto(PartyMessageRequestDto dto) {
|
||||||
|
return PartyChatFromWeb.builder()
|
||||||
|
.sender(dto.getFrom())
|
||||||
|
.message(dto.getMessage())
|
||||||
|
.partyId(dto.getPartyId())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server.mappers;
|
||||||
|
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@UtilityClass
|
||||||
|
public class PlayerListStateMapper {
|
||||||
|
|
||||||
|
public static PlayerListState fromList(ArrayList<UUID> activeViewers) {
|
||||||
|
return PlayerListState.builder()
|
||||||
|
.activePlayers(activeViewers)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server.mappers;
|
||||||
|
|
||||||
|
import com.alttd.altitudeweb.model.PrivateMessageRequestDto;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.PrivateChatFromWeb;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
@UtilityClass
|
||||||
|
public class PrivateChatFromWebMapper {
|
||||||
|
|
||||||
|
public static PrivateChatFromWeb fromDto(PrivateMessageRequestDto dto) {
|
||||||
|
return PrivateChatFromWeb.builder()
|
||||||
|
.sender(dto.getFrom())
|
||||||
|
.message(dto.getMessage())
|
||||||
|
.recipient(dto.getTo())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server.mappers;
|
||||||
|
|
||||||
|
import com.alttd.altitudeweb.model.PunishRequestDto;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.PunishFromWeb;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
@UtilityClass
|
||||||
|
public class PunishFromWebMapper {
|
||||||
|
|
||||||
|
public static PunishFromWeb fromDto(PunishRequestDto dto) {
|
||||||
|
return PunishFromWeb.builder()
|
||||||
|
.executor(dto.getExecutor())
|
||||||
|
.target(dto.getTarget())
|
||||||
|
.type(dto.getType().getValue())
|
||||||
|
.reason(dto.getReason())
|
||||||
|
.time(dto.getTime())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.alttd.altitudeweb.controllers.chat;
|
package com.alttd.altitudeweb.controllers.chat;
|
||||||
|
|
||||||
|
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||||
import com.alttd.altitudeweb.model.ServerStateDto;
|
import com.alttd.altitudeweb.model.ServerStateDto;
|
||||||
|
import com.alttd.altitudeweb.model.PlayerListVisibilityDto;
|
||||||
import com.alttd.altitudeweb.services.chat.ChatService;
|
import com.alttd.altitudeweb.services.chat.ChatService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -10,6 +12,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
@@ -19,6 +23,9 @@ class ChatControllerTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private ChatService chatService;
|
private ChatService chatService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AuthenticatedUuid authenticatedUuid;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ChatController chatController;
|
private ChatController chatController;
|
||||||
|
|
||||||
@@ -31,4 +38,38 @@ class ChatControllerTest {
|
|||||||
verify(chatService).updateServerState(serverStateDto);
|
verify(chatService).updateServerState(serverStateDto);
|
||||||
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
|
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hideUserFromPlayerListDelegatesForAuthenticatedUser() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
|
||||||
|
|
||||||
|
ResponseEntity<Void> response = chatController.hideUserFromPlayerList();
|
||||||
|
|
||||||
|
verify(chatService).hideUserFromPlayerList(uuid);
|
||||||
|
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void showUserInPlayerListDelegatesForAuthenticatedUser() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
|
||||||
|
|
||||||
|
ResponseEntity<Void> response = chatController.showUserInPlayerList();
|
||||||
|
|
||||||
|
verify(chatService).showUserInPlayerList(uuid);
|
||||||
|
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPlayerListVisibilityReturnsBackendState() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
|
||||||
|
org.mockito.Mockito.when(chatService.isUserHiddenFromPlayerList(uuid)).thenReturn(true);
|
||||||
|
|
||||||
|
ResponseEntity<PlayerListVisibilityDto> response = chatController.getPlayerListVisibility();
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
|
assertEquals(true, response.getBody().getHidden());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,22 @@ import com.alttd.altitudeweb.model.ServerStateDto;
|
|||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
|
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
@@ -26,11 +31,14 @@ import static org.mockito.Mockito.*;
|
|||||||
class ChatServiceTest {
|
class ChatServiceTest {
|
||||||
|
|
||||||
private ChatService chatService;
|
private ChatService chatService;
|
||||||
|
private EventPublisher eventPublisher;
|
||||||
|
private ServerMessageService serverMessageService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
EventPublisher eventPublisher = mock(EventPublisher.class);
|
eventPublisher = mock(EventPublisher.class);
|
||||||
chatService = spy(new ChatService(eventPublisher));
|
serverMessageService = mock(ServerMessageService.class);
|
||||||
|
chatService = spy(new ChatService(eventPublisher, serverMessageService));
|
||||||
ReflectionTestUtils.setField(chatService, "allowedServers", new String[]{"server1"});
|
ReflectionTestUtils.setField(chatService, "allowedServers", new String[]{"server1"});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,4 +187,53 @@ class ChatServiceTest {
|
|||||||
assertTrue(headModResult.contains("server1"), "HEAD_MOD should see allowed server");
|
assertTrue(headModResult.contains("server1"), "HEAD_MOD should see allowed server");
|
||||||
assertTrue(headModResult.contains("server2"), "HEAD_MOD should see non-allowed server");
|
assertTrue(headModResult.contains("server2"), "HEAD_MOD should see non-allowed server");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void subscribingUserBroadcastsActivePlayerStateOnce() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
EventUser eventUser = new EventUser(uuid, List.of(), null);
|
||||||
|
|
||||||
|
chatService.subscribe(eventUser, "[]");
|
||||||
|
chatService.subscribe(eventUser, "[]");
|
||||||
|
|
||||||
|
ArgumentCaptor<PlayerListState> stateCaptor = ArgumentCaptor.forClass(PlayerListState.class);
|
||||||
|
verify(serverMessageService, times(1)).sendMessageToAll(eq("player_state"), stateCaptor.capture());
|
||||||
|
assertEquals(List.of(uuid), stateCaptor.getValue().getActivePlayers());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headModCanHideCurrentSessionFromPlayerState() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
EventUser eventUser = new EventUser(uuid, List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
|
||||||
|
chatService.subscribe(eventUser, "[]");
|
||||||
|
clearInvocations(serverMessageService);
|
||||||
|
|
||||||
|
chatService.hideUserFromPlayerList(uuid);
|
||||||
|
|
||||||
|
ArgumentCaptor<PlayerListState> stateCaptor = ArgumentCaptor.forClass(PlayerListState.class);
|
||||||
|
verify(serverMessageService).sendMessageToAll(eq("player_state"), stateCaptor.capture());
|
||||||
|
assertTrue(stateCaptor.getValue().getActivePlayers().isEmpty());
|
||||||
|
assertTrue(chatService.isUserHiddenFromPlayerList(uuid));
|
||||||
|
|
||||||
|
clearInvocations(serverMessageService);
|
||||||
|
chatService.showUserInPlayerList(uuid);
|
||||||
|
|
||||||
|
verify(serverMessageService).sendMessageToAll(eq("player_state"), stateCaptor.capture());
|
||||||
|
assertEquals(List.of(uuid), stateCaptor.getValue().getActivePlayers());
|
||||||
|
assertFalse(chatService.isUserHiddenFromPlayerList(uuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void modCannotHideCurrentSessionFromPlayerState() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
EventUser eventUser = new EventUser(uuid, List.of(PermissionClaimDto.MOD.getValue()), null);
|
||||||
|
chatService.subscribe(eventUser, "[]");
|
||||||
|
clearInvocations(serverMessageService);
|
||||||
|
|
||||||
|
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||||
|
() -> chatService.hideUserFromPlayerList(uuid));
|
||||||
|
|
||||||
|
assertEquals(403, exception.getStatusCode().value());
|
||||||
|
verifyNoInteractions(serverMessageService);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.alttd.altitudeweb.services.chat.to_server;
|
||||||
|
|
||||||
|
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
||||||
|
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ServerMessageServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void broadcastsSerializedPlayerStateToEveryServer() {
|
||||||
|
EventPublisher eventPublisher = mock(EventPublisher.class);
|
||||||
|
when(eventPublisher.subscribe(any(), any(EventPublisher.SessionCleanupCallback.class)))
|
||||||
|
.thenReturn(new SseEmitter());
|
||||||
|
ServerMessageService service = new ServerMessageService(eventPublisher);
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
service.subscribe("server-one");
|
||||||
|
service.subscribe("server-two");
|
||||||
|
service.sendMessageToAll("player_state", PlayerListState.builder()
|
||||||
|
.activePlayers(List.of(uuid))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
String expectedJson = "{\"activePlayers\":[\"" + uuid + "\"]}";
|
||||||
|
verify(eventPublisher).sendToUser("server-one", "player_state", expectedJson);
|
||||||
|
verify(eventPublisher).sendToUser("server-two", "player_state", expectedJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newlySubscribedServerReceivesLatestPlayerState() {
|
||||||
|
EventPublisher eventPublisher = mock(EventPublisher.class);
|
||||||
|
when(eventPublisher.subscribe(eq("server-one"), any(EventPublisher.SessionCleanupCallback.class)))
|
||||||
|
.thenReturn(new SseEmitter());
|
||||||
|
ServerMessageService service = new ServerMessageService(eventPublisher);
|
||||||
|
|
||||||
|
service.sendMessageToAll("player_state", PlayerListState.builder()
|
||||||
|
.activePlayers(List.of())
|
||||||
|
.build());
|
||||||
|
service.subscribe("server-one");
|
||||||
|
|
||||||
|
verify(eventPublisher).sendToUser("server-one", "player_state", "{\"activePlayers\":[]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,16 @@
|
|||||||
(keyup.enter)="sendMessage()"
|
(keyup.enter)="sendMessage()"
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
maxlength="256"
|
maxlength="256"
|
||||||
|
[disabled]="hiddenMode()"
|
||||||
/>
|
/>
|
||||||
<button (click)="sendMessage()">Send</button>
|
<button (click)="sendMessage()" [disabled]="hiddenMode()">Send</button>
|
||||||
|
<button
|
||||||
|
class="visibility-toggle"
|
||||||
|
[class.active]="hiddenMode()"
|
||||||
|
[disabled]="!visibilityConnected() || visibilityUpdating()"
|
||||||
|
(click)="toggleHiddenMode()"
|
||||||
|
>
|
||||||
|
{{ hiddenMode() ? 'Show Online' : 'Hide Online' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,14 @@
|
|||||||
&:active {
|
&:active {
|
||||||
background: #2a6ae0;
|
background: #2a6ae0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.visibility-toggle.active {
|
||||||
|
background: #b34747;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,18 +26,22 @@ export class ChatBoxComponent {
|
|||||||
private readonly matSnackBar = inject(MatSnackBar);
|
private readonly matSnackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
readonly messages = input.required<ChatMessage[]>();
|
readonly messages = input.required<ChatMessage[]>();
|
||||||
|
readonly hiddenMode = this.chatService.hiddenMode;
|
||||||
|
readonly visibilityConnected = this.chatService.connected;
|
||||||
|
readonly visibilityUpdating = this.chatService.visibilityUpdating;
|
||||||
chatText = '';
|
chatText = '';
|
||||||
|
|
||||||
readonly canChat = computed(() => {
|
readonly canChat = computed(() => {
|
||||||
const hasPrivilege = this.authService.hasAccess(['SCOPE_head_mod']);
|
const hasPrivilege = this.authService.hasAccess(['SCOPE_head_mod']);
|
||||||
const isServerChannel = this.chatService.selectedChannel()?.type === 'SERVER';
|
const channelType = this.chatService.selectedChannel()?.type;
|
||||||
if (!hasPrivilege || !isServerChannel) {
|
const isValidChannel = this.chatService.selectedChannel()?.type === 'SERVER' || this.chatService.selectedChannel()?.type === 'DM';
|
||||||
console.log(`User has privilege ${hasPrivilege}, user in server channel ${isServerChannel}`);
|
return hasPrivilege && isValidChannel;
|
||||||
}
|
|
||||||
return hasPrivilege && isServerChannel;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sendMessage() {
|
sendMessage() {
|
||||||
|
if (this.hiddenMode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const message = this.chatText.trim();
|
const message = this.chatText.trim();
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return;
|
return;
|
||||||
@@ -68,4 +72,8 @@ export class ChatBoxComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleHiddenMode(): void {
|
||||||
|
this.chatService.toggleHiddenMode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export class UserListComponent {
|
|||||||
|
|
||||||
protected readonly serverState = computed(() => {
|
protected readonly serverState = computed(() => {
|
||||||
const state = this.chatService.serverState();
|
const state = this.chatService.serverState();
|
||||||
if (!state) return null;
|
if (!state) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
servers: [...state.servers]
|
servers: [...state.servers]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {computed, inject, Injectable, OnDestroy, signal} from '@angular/core';
|
import {computed, inject, Injectable, OnDestroy, signal} from '@angular/core';
|
||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
import {AuthService} from '@services/auth.service';
|
import {AuthService} from '@services/auth.service';
|
||||||
import {EventSourcePolyfill} from 'event-source-polyfill';
|
import {EventSourcePolyfill} from 'event-source-polyfill';
|
||||||
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
|
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
|
||||||
@@ -9,6 +10,7 @@ import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
|
|||||||
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
|
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
|
||||||
import {ChatInfoService} from '@api';
|
import {ChatInfoService} from '@api';
|
||||||
import {ServerState} from '@pages/altitude/chat/objects/server-state.object';
|
import {ServerState} from '@pages/altitude/chat/objects/server-state.object';
|
||||||
|
import {CookieService} from 'ngx-cookie-service';
|
||||||
|
|
||||||
interface SsePayloadEvent {
|
interface SsePayloadEvent {
|
||||||
data: string;
|
data: string;
|
||||||
@@ -16,10 +18,15 @@ interface SsePayloadEvent {
|
|||||||
|
|
||||||
@Injectable({providedIn: 'root'})
|
@Injectable({providedIn: 'root'})
|
||||||
export class ChatService implements OnDestroy {
|
export class ChatService implements OnDestroy {
|
||||||
|
private readonly HIDDEN_MODE_COOKIE = 'chat-hidden-mode';
|
||||||
private eventSource?: EventSourcePolyfill;
|
private eventSource?: EventSourcePolyfill;
|
||||||
|
private visibilityPollId?: number;
|
||||||
|
private visibilityRevision = 0;
|
||||||
private readonly notificationService: NotificationService = inject(NotificationService);
|
private readonly notificationService: NotificationService = inject(NotificationService);
|
||||||
private readonly authService: AuthService = inject(AuthService)
|
private readonly authService: AuthService = inject(AuthService)
|
||||||
private readonly chatInfoService: ChatInfoService = inject(ChatInfoService);
|
private readonly chatInfoService: ChatInfoService = inject(ChatInfoService);
|
||||||
|
private readonly httpClient = inject(HttpClient);
|
||||||
|
private readonly cookieService = inject(CookieService);
|
||||||
private readonly _messages = signal<ChatMessage[]>([])
|
private readonly _messages = signal<ChatMessage[]>([])
|
||||||
private readonly _channels = signal<ChatChannel[]>([])
|
private readonly _channels = signal<ChatChannel[]>([])
|
||||||
public readonly channels = computed(() => this._channels().sort((a, b) => a.name.localeCompare(b.name)));
|
public readonly channels = computed(() => this._channels().sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
@@ -30,6 +37,12 @@ export class ChatService implements OnDestroy {
|
|||||||
public readonly partieMap = signal<Map<string, string>>(new Map());
|
public readonly partieMap = signal<Map<string, string>>(new Map());
|
||||||
public readonly userNameMap = signal<Map<string, string>>(new Map());
|
public readonly userNameMap = signal<Map<string, string>>(new Map());
|
||||||
public readonly userListVisible = signal(true);
|
public readonly userListVisible = signal(true);
|
||||||
|
private readonly _connected = signal(false);
|
||||||
|
public readonly connected = this._connected.asReadonly();
|
||||||
|
private readonly _hiddenMode = signal(this.cookieService.get(this.HIDDEN_MODE_COOKIE) === 'true');
|
||||||
|
public readonly hiddenMode = this._hiddenMode.asReadonly();
|
||||||
|
private readonly _visibilityUpdating = signal(false);
|
||||||
|
public readonly visibilityUpdating = this._visibilityUpdating.asReadonly();
|
||||||
private readonly pendingPartyNameRequests = new Set<string>();
|
private readonly pendingPartyNameRequests = new Set<string>();
|
||||||
private readonly pendingUserNameRequests = new Set<string>();
|
private readonly pendingUserNameRequests = new Set<string>();
|
||||||
|
|
||||||
@@ -67,6 +80,9 @@ export class ChatService implements OnDestroy {
|
|||||||
|
|
||||||
this.on(source, 'connected', (event) => {
|
this.on(source, 'connected', (event) => {
|
||||||
this.processChatMessages(event);
|
this.processChatMessages(event);
|
||||||
|
this._connected.set(true);
|
||||||
|
this.restoreVisibilityAfterConnection();
|
||||||
|
this.startVisibilityPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.on(source, 'chat', (event) => {
|
this.on(source, 'chat', (event) => {
|
||||||
@@ -92,10 +108,77 @@ export class ChatService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
source.onerror = (err) => {
|
source.onerror = (err) => {
|
||||||
|
this._connected.set(false);
|
||||||
console.error('SSE error, polyfill will auto-reconnect:', err);
|
console.error('SSE error, polyfill will auto-reconnect:', err);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public toggleHiddenMode(): void {
|
||||||
|
if (!this._connected() || this._visibilityUpdating()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setBackendHiddenMode(!this._hiddenMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreVisibilityAfterConnection(): void {
|
||||||
|
if (this._hiddenMode()) {
|
||||||
|
this.setBackendHiddenMode(true);
|
||||||
|
} else {
|
||||||
|
this.validateVisibility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setBackendHiddenMode(hidden: boolean): void {
|
||||||
|
if (!this._connected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = ++this.visibilityRevision;
|
||||||
|
this._visibilityUpdating.set(true);
|
||||||
|
const path = hidden ? '/api/chat/hide' : '/api/chat/show';
|
||||||
|
this.httpClient.post<void>(path, null).subscribe({
|
||||||
|
next: () => {
|
||||||
|
if (revision === this.visibilityRevision) {
|
||||||
|
this.applyHiddenMode(hidden);
|
||||||
|
}
|
||||||
|
this._visibilityUpdating.set(false);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error(`Failed to ${hidden ? 'hide' : 'show'} chat session:`, error);
|
||||||
|
this._visibilityUpdating.set(false);
|
||||||
|
this.validateVisibility();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startVisibilityPolling(): void {
|
||||||
|
if (this.visibilityPollId !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.visibilityPollId = window.setInterval(() => this.validateVisibility(), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateVisibility(): void {
|
||||||
|
if (!this._connected() || this._visibilityUpdating()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = this.visibilityRevision;
|
||||||
|
this.httpClient.get<{hidden: boolean}>('/api/chat/visibility').subscribe({
|
||||||
|
next: (visibility) => {
|
||||||
|
if (revision === this.visibilityRevision) {
|
||||||
|
this.applyHiddenMode(visibility.hidden);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error) => console.error('Failed to validate chat visibility:', error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyHiddenMode(hidden: boolean): void {
|
||||||
|
this._hiddenMode.set(hidden);
|
||||||
|
this.cookieService.set(this.HIDDEN_MODE_COOKIE, String(hidden), 365, '/');
|
||||||
|
}
|
||||||
|
|
||||||
private processChatMessages(event: SsePayloadEvent): ChatMessage[] {
|
private processChatMessages(event: SsePayloadEvent): ChatMessage[] {
|
||||||
const raw = JSON.parse(event.data) as RawChatMessage[];
|
const raw = JSON.parse(event.data) as RawChatMessage[];
|
||||||
const messages: ChatMessage[] = raw.map((m) => {
|
const messages: ChatMessage[] = raw.map((m) => {
|
||||||
@@ -222,6 +305,11 @@ export class ChatService implements OnDestroy {
|
|||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.eventSource?.close();
|
this.eventSource?.close();
|
||||||
this.eventSource = undefined;
|
this.eventSource = undefined;
|
||||||
|
this._connected.set(false);
|
||||||
|
if (this.visibilityPollId !== undefined) {
|
||||||
|
window.clearInterval(this.visibilityPollId);
|
||||||
|
this.visibilityPollId = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const ALTITUDE_VERSION = "1.21.10"
|
export const ALTITUDE_VERSION = "26.2"
|
||||||
|
|
||||||
export const enum THEME_MODE {
|
export const enum THEME_MODE {
|
||||||
LIGHT = 'theme-light',
|
LIGHT = 'theme-light',
|
||||||
|
|||||||
@@ -107,3 +107,11 @@ paths:
|
|||||||
$ref: './schemas/chat_info/chat_info.yml#/UserName'
|
$ref: './schemas/chat_info/chat_info.yml#/UserName'
|
||||||
/api/site/chat/send-message/{server}:
|
/api/site/chat/send-message/{server}:
|
||||||
$ref: './schemas/server-message/server-message.yml#/sendServerMessage'
|
$ref: './schemas/server-message/server-message.yml#/sendServerMessage'
|
||||||
|
/api/site/chat/send-admin-chat/{server}:
|
||||||
|
$ref: './schemas/server-message/server-message.yml#/sendAdminChat'
|
||||||
|
/api/site/chat/send-private-message:
|
||||||
|
$ref: './schemas/server-message/server-message.yml#/sendPrivateMessage'
|
||||||
|
/api/site/chat/send-party-message:
|
||||||
|
$ref: './schemas/server-message/server-message.yml#/sendPartyMessage'
|
||||||
|
/api/site/chat/punish/{server}:
|
||||||
|
$ref: './schemas/server-message/server-message.yml#/punishPlayer'
|
||||||
|
|||||||
@@ -12,6 +12,40 @@ tags:
|
|||||||
description: Data for displaying Chat messages to clients
|
description: Data for displaying Chat messages to clients
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
|
/chat/hide:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- chat
|
||||||
|
summary: Hide the authenticated user from the active player list for this session
|
||||||
|
operationId: hideUserFromPlayerList
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: User hidden for the current session
|
||||||
|
|
||||||
|
/chat/show:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- chat
|
||||||
|
summary: Show the authenticated user in the active player list for this session
|
||||||
|
operationId: showUserInPlayerList
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: User shown for the current session
|
||||||
|
|
||||||
|
/chat/visibility:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- chat
|
||||||
|
summary: Get the authenticated user's active player list visibility
|
||||||
|
operationId: getPlayerListVisibility
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Current visibility mode
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PlayerListVisibility"
|
||||||
|
|
||||||
/chat/send/chat/message:
|
/chat/send/chat/message:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -47,6 +81,14 @@ paths:
|
|||||||
description: Accepted
|
description: Accepted
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
|
PlayerListVisibility:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- hidden
|
||||||
|
properties:
|
||||||
|
hidden:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
User:
|
User:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
@@ -19,6 +19,82 @@ sendServerMessage:
|
|||||||
description: Server not found or not connected
|
description: Server not found or not connected
|
||||||
'400':
|
'400':
|
||||||
description: Invalid request
|
description: Invalid request
|
||||||
|
sendAdminChat:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- server-message
|
||||||
|
summary: Send an admin chat to a specific server
|
||||||
|
operationId: sendAdminChat
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Server'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ServerMessageRequest'
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Message accepted and sent
|
||||||
|
'404':
|
||||||
|
description: Server not found or not connected
|
||||||
|
'400':
|
||||||
|
description: Invalid request
|
||||||
|
sendPrivateMessage:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- server-message
|
||||||
|
summary: Send a private message
|
||||||
|
operationId: sendPrivateMessage
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PrivateMessageRequest'
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Message accepted and sent
|
||||||
|
'400':
|
||||||
|
description: Invalid request
|
||||||
|
sendPartyMessage:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- server-message
|
||||||
|
summary: Send a party message
|
||||||
|
operationId: sendPartyMessage
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PartyMessageRequest'
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Message accepted and sent
|
||||||
|
'400':
|
||||||
|
description: Invalid request
|
||||||
|
punishPlayer:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- server-message
|
||||||
|
summary: Punish a player on a specific server
|
||||||
|
operationId: punishPlayer
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Server'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PunishRequest'
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Punishment accepted and applied
|
||||||
|
'404':
|
||||||
|
description: Server not found or not connected
|
||||||
|
'400':
|
||||||
|
description: Invalid request
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
Server:
|
Server:
|
||||||
@@ -43,3 +119,74 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: The message to send
|
description: The message to send
|
||||||
maxLength: 256
|
maxLength: 256
|
||||||
|
PrivateMessageRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- from
|
||||||
|
- message
|
||||||
|
- to
|
||||||
|
properties:
|
||||||
|
from:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: The sender UUID
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: The message to send
|
||||||
|
maxLength: 256
|
||||||
|
to:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: The recipient UUID
|
||||||
|
|
||||||
|
PartyMessageRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- from
|
||||||
|
- message
|
||||||
|
- partyId
|
||||||
|
properties:
|
||||||
|
from:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: The sender UUID
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: The message to send
|
||||||
|
maxLength: 256
|
||||||
|
partyId:
|
||||||
|
type: string
|
||||||
|
format: int32
|
||||||
|
description: The party id
|
||||||
|
|
||||||
|
PunishRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- executor
|
||||||
|
- target
|
||||||
|
- type
|
||||||
|
- reason
|
||||||
|
properties:
|
||||||
|
executor:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: The executor UUID
|
||||||
|
target:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: The target UUID
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: The type of punishment
|
||||||
|
enum:
|
||||||
|
- BAN
|
||||||
|
- MUTE
|
||||||
|
- WARN
|
||||||
|
- FLAG
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
description: The reason for the punishment
|
||||||
|
time:
|
||||||
|
type: string
|
||||||
|
format: duration
|
||||||
|
example: PT30S
|
||||||
|
|||||||
Reference in New Issue
Block a user