diff --git a/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java b/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java index 228d5332f..fd5d0097d 100644 --- a/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java +++ b/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java @@ -48,6 +48,9 @@ public String serialize(FriendListResponseEvent value) { entryJson.put("nickname", entry.nickname != null ? entry.nickname : JSONObject.NULL); entryJson.put("isBest", entry.isBest); entryJson.put("isOnline", entry.isOnline); + entryJson.put("lastSeen", entry.lastSeen); + entryJson.put("friendSince", entry.friendSince); + entryJson.put("server", entry.server != null ? entry.server : JSONObject.NULL); friendsArray.put(entryJson); } json.put("friends", friendsArray); @@ -71,7 +74,10 @@ public FriendListResponseEvent deserialize(String json) { entryJson.getString("name"), entryJson.isNull("nickname") ? null : entryJson.getString("nickname"), entryJson.getBoolean("isBest"), - entryJson.getBoolean("isOnline") + entryJson.getBoolean("isOnline"), + entryJson.optLong("lastSeen", 0L), + entryJson.optLong("friendSince", 0L), + entryJson.isNull("server") ? null : entryJson.getString("server") )); } @@ -98,13 +104,19 @@ public static class FriendListEntry { private final String nickname; private final boolean isBest; private final boolean isOnline; + private final long lastSeen; + private final long friendSince; + private final String server; - public FriendListEntry(UUID uuid, String name, String nickname, boolean isBest, boolean isOnline) { + public FriendListEntry(UUID uuid, String name, String nickname, boolean isBest, boolean isOnline, long lastSeen, long friendSince, String server) { this.uuid = uuid; this.name = name; this.nickname = nickname; this.isBest = isBest; this.isOnline = isOnline; + this.lastSeen = lastSeen; + this.friendSince = friendSince; + this.server = server; } } } diff --git a/commons/src/main/java/net/swofty/commons/presence/PresenceInfo.java b/commons/src/main/java/net/swofty/commons/presence/PresenceInfo.java new file mode 100644 index 000000000..1c8b09291 --- /dev/null +++ b/commons/src/main/java/net/swofty/commons/presence/PresenceInfo.java @@ -0,0 +1,57 @@ +package net.swofty.commons.presence; + +import lombok.Getter; +import net.swofty.commons.protocol.Serializer; +import org.json.JSONObject; + +import java.util.UUID; + +@Getter +public class PresenceInfo { + private final UUID uuid; + private final boolean online; + private final String serverType; + private final String serverId; + private final long lastSeen; + + public PresenceInfo(UUID uuid, boolean online, String serverType, String serverId, long lastSeen) { + this.uuid = uuid; + this.online = online; + this.serverType = serverType; + this.serverId = serverId; + this.lastSeen = lastSeen; + } + + public static Serializer getSerializer() { + return new Serializer<>() { + @Override + public String serialize(PresenceInfo value) { + JSONObject json = new JSONObject(); + json.put("uuid", value.uuid.toString()); + json.put("online", value.online); + json.put("serverType", value.serverType != null ? value.serverType : JSONObject.NULL); + json.put("serverId", value.serverId != null ? value.serverId : JSONObject.NULL); + json.put("lastSeen", value.lastSeen); + return json.toString(); + } + + @Override + public PresenceInfo deserialize(String json) { + JSONObject jsonObject = new JSONObject(json); + return new PresenceInfo( + UUID.fromString(jsonObject.getString("uuid")), + jsonObject.getBoolean("online"), + jsonObject.isNull("serverType") ? null : jsonObject.getString("serverType"), + jsonObject.isNull("serverId") ? null : jsonObject.getString("serverId"), + jsonObject.getLong("lastSeen") + ); + } + + @Override + public PresenceInfo clone(PresenceInfo value) { + return new PresenceInfo(value.uuid, value.online, value.serverType, value.serverId, value.lastSeen); + } + }; + } +} + diff --git a/commons/src/main/java/net/swofty/commons/protocol/objects/presence/GetPresenceBulkProtocolObject.java b/commons/src/main/java/net/swofty/commons/protocol/objects/presence/GetPresenceBulkProtocolObject.java new file mode 100644 index 000000000..7bb662339 --- /dev/null +++ b/commons/src/main/java/net/swofty/commons/protocol/objects/presence/GetPresenceBulkProtocolObject.java @@ -0,0 +1,85 @@ +package net.swofty.commons.protocol.objects.presence; + +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.ProtocolObject; +import net.swofty.commons.protocol.Serializer; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class GetPresenceBulkProtocolObject extends ProtocolObject< + GetPresenceBulkProtocolObject.GetPresenceBulkMessage, + GetPresenceBulkProtocolObject.GetPresenceBulkResponse> { + + @Override + public Serializer getSerializer() { + return new Serializer<>() { + @Override + public String serialize(GetPresenceBulkMessage value) { + JSONArray array = new JSONArray(); + for (UUID uuid : value.uuids) { + array.put(uuid.toString()); + } + JSONObject json = new JSONObject(); + json.put("uuids", array); + return json.toString(); + } + + @Override + public GetPresenceBulkMessage deserialize(String json) { + JSONObject obj = new JSONObject(json); + JSONArray array = obj.getJSONArray("uuids"); + List ids = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + ids.add(UUID.fromString(array.getString(i))); + } + return new GetPresenceBulkMessage(ids); + } + + @Override + public GetPresenceBulkMessage clone(GetPresenceBulkMessage value) { + return new GetPresenceBulkMessage(new ArrayList<>(value.uuids)); + } + }; + } + + @Override + public Serializer getReturnSerializer() { + return new Serializer<>() { + @Override + public String serialize(GetPresenceBulkResponse value) { + JSONArray array = new JSONArray(); + for (PresenceInfo info : value.presence()) { + array.put(new JSONObject(PresenceInfo.getSerializer().serialize(info))); + } + JSONObject json = new JSONObject(); + json.put("presence", array); + return json.toString(); + } + + @Override + public GetPresenceBulkResponse deserialize(String json) { + JSONObject obj = new JSONObject(json); + JSONArray array = obj.getJSONArray("presence"); + List presence = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + presence.add(PresenceInfo.getSerializer().deserialize(array.getJSONObject(i).toString())); + } + return new GetPresenceBulkResponse(presence); + } + + @Override + public GetPresenceBulkResponse clone(GetPresenceBulkResponse value) { + return new GetPresenceBulkResponse(new ArrayList<>(value.presence())); + } + }; + } + + public record GetPresenceBulkMessage(List uuids) {} + + public record GetPresenceBulkResponse(List presence) {} +} + diff --git a/commons/src/main/java/net/swofty/commons/protocol/objects/presence/UpdatePresenceProtocolObject.java b/commons/src/main/java/net/swofty/commons/protocol/objects/presence/UpdatePresenceProtocolObject.java new file mode 100644 index 000000000..689d5cc09 --- /dev/null +++ b/commons/src/main/java/net/swofty/commons/protocol/objects/presence/UpdatePresenceProtocolObject.java @@ -0,0 +1,60 @@ +package net.swofty.commons.protocol.objects.presence; + +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.ProtocolObject; +import net.swofty.commons.protocol.Serializer; +import org.json.JSONObject; + +public class UpdatePresenceProtocolObject extends ProtocolObject< + UpdatePresenceProtocolObject.UpdatePresenceMessage, + UpdatePresenceProtocolObject.UpdatePresenceResponse> { + + @Override + public Serializer getSerializer() { + return new Serializer<>() { + @Override + public String serialize(UpdatePresenceMessage value) { + JSONObject json = new JSONObject(); + json.put("presence", new JSONObject(PresenceInfo.getSerializer().serialize(value.presence()))); + return json.toString(); + } + + @Override + public UpdatePresenceMessage deserialize(String json) { + JSONObject obj = new JSONObject(json); + PresenceInfo presence = PresenceInfo.getSerializer().deserialize(obj.getJSONObject("presence").toString()); + return new UpdatePresenceMessage(presence); + } + + @Override + public UpdatePresenceMessage clone(UpdatePresenceMessage value) { + return new UpdatePresenceMessage(value.presence()); + } + }; + } + + @Override + public Serializer getReturnSerializer() { + return new Serializer<>() { + @Override + public String serialize(UpdatePresenceResponse value) { + return value.success ? "true" : "false"; + } + + @Override + public UpdatePresenceResponse deserialize(String json) { + return new UpdatePresenceResponse(Boolean.parseBoolean(json)); + } + + @Override + public UpdatePresenceResponse clone(UpdatePresenceResponse value) { + return new UpdatePresenceResponse(value.success); + } + }; + } + + public record UpdatePresenceMessage(PresenceInfo presence) {} + + public record UpdatePresenceResponse(boolean success) {} +} + diff --git a/proxy.api/src/main/java/net/swofty/proxyapi/redis/ServerOutboundMessage.java b/proxy.api/src/main/java/net/swofty/proxyapi/redis/ServerOutboundMessage.java index 424ea9e6d..9a721cd3f 100644 --- a/proxy.api/src/main/java/net/swofty/proxyapi/redis/ServerOutboundMessage.java +++ b/proxy.api/src/main/java/net/swofty/proxyapi/redis/ServerOutboundMessage.java @@ -90,6 +90,42 @@ public static void sendMessageToService(ServiceType service, specification.channel(), message).toJSON().toString()); } + /** + * Fire-and-forget: send to a service and do not wait for or register a response. + */ + public static void sendMessageToServiceFireAndForget(ServiceType service, + ProtocolObject specification, + Object rawMessage) { + UUID requestId = UUID.randomUUID(); + String callback = null; + try { + callback = RedisAPI.getInstance().getFilterId(); + } catch (Exception ignored) { + } + + String message = specification.translateToString(rawMessage); + RedisAPI.getInstance().publishMessage( + service.name(), + ChannelRegistry.getFromName(specification.channel()), + new ServiceProxyRequest( + requestId, + callback != null ? callback : "proxy", + specification.channel(), + message + ).toJSON().toString() + ); + } + + /** + * Fire-and-forget broadcast to all service types. + */ + public static void sendMessageToAllServicesFireAndForget(ProtocolObject specification, + Object rawMessage) { + for (ServiceType serviceType : ServiceType.values()) { + sendMessageToServiceFireAndForget(serviceType, specification, rawMessage); + } + } + private static String getRequestTypeName(ProtocolObject protocolObject) { Class clazz = protocolObject.getClass(); Type genericSuperclass = clazz.getGenericSuperclass(); diff --git a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java index 7e01c85c7..e60e8e131 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java +++ b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java @@ -1,15 +1,23 @@ package net.swofty.service.friend; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; import net.swofty.commons.friend.*; import net.swofty.commons.friend.events.*; import net.swofty.commons.friend.events.response.*; import net.swofty.commons.service.FromServiceChannels; import net.swofty.service.generic.redis.ServiceToServerManager; import org.json.JSONObject; +import org.bson.Document; +import org.tinylog.Logger; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; @@ -276,7 +284,7 @@ public static void handleToggleSettingRequest(FriendToggleSettingRequestEvent ev sendEvent(new FriendSettingToggledResponseEvent(player, settingType, newValue)); } - public static void handleListRequest(FriendListRequestEvent event, Map playerNames, Map onlineStatus) { + public static void handleListRequest(FriendListRequestEvent event) { UUID player = event.getPlayer(); int page = event.getPage(); boolean bestOnly = event.isBestOnly(); @@ -291,24 +299,40 @@ public static void handleListRequest(FriendListRequestEvent event, Map pageFriends = friends.subList(startIndex, endIndex); + Map playerNames = resolvePlayerNames(pageFriends.stream() + .map(Friend::getUuid) + .toList()); + List friendUuids = pageFriends.stream().map(Friend::getUuid).toList(); + Map onlineStatus = PresenceStorage.getOnlineStatus(friendUuids); + Map presenceInfo = PresenceStorage.getMap(friendUuids); + List entries = new ArrayList<>(); - for (int i = startIndex; i < endIndex; i++) { - Friend friend = friends.get(i); + for (Friend friend : pageFriends) { String name = playerNames.getOrDefault(friend.getUuid(), "Unknown"); boolean isOnline = onlineStatus.getOrDefault(friend.getUuid(), false); + net.swofty.commons.presence.PresenceInfo pInfo = presenceInfo.get(friend.getUuid()); + long lastSeen = pInfo != null ? pInfo.getLastSeen() : 0L; + String server = (pInfo != null && pInfo.isOnline() && pInfo.getServerType() != null) + ? formatServerDisplay(pInfo) + : null; + long friendSince = friend.getAddedTimestamp(); entries.add(new FriendListResponseEvent.FriendListEntry( friend.getUuid(), name, friend.getNickname(), friend.isBestFriend(), - isOnline + isOnline, + lastSeen, + friendSince, + server )); } sendEvent(new FriendListResponseEvent(player, entries, page, totalPages, bestOnly)); } - public static void handleRequestsListRequest(FriendRequestsListEvent event, Map playerNames) { + public static void handleRequestsListRequest(FriendRequestsListEvent event) { UUID player = event.getPlayer(); int page = event.getPage(); @@ -322,10 +346,14 @@ public static void handleRequestsListRequest(FriendRequestsListEvent event, Map< int startIndex = (page - 1) * FRIENDS_PER_PAGE; int endIndex = Math.min(startIndex + FRIENDS_PER_PAGE, totalRequests); + List pageRequests = requests.subList(startIndex, endIndex); + Map playerNames = resolvePlayerNames(pageRequests.stream() + .map(PendingFriendRequest::getFrom) + .toList()); + List entries = new ArrayList<>(); - for (int i = startIndex; i < endIndex; i++) { - PendingFriendRequest request = requests.get(i); - String senderName = playerNames.getOrDefault(request.getFrom(), "Unknown"); + for (PendingFriendRequest request : pageRequests) { + String senderName = playerNames.getOrDefault(request.getFrom(), request.getFromName()); entries.add(new FriendRequestsListResponseEvent.FriendRequestEntry( request.getFrom(), senderName, @@ -337,6 +365,14 @@ public static void handleRequestsListRequest(FriendRequestsListEvent event, Map< } public static void handlePlayerJoin(UUID playerUuid, String playerName) { + PresenceStorage.upsert(new net.swofty.commons.presence.PresenceInfo( + playerUuid, + true, + null, + null, + System.currentTimeMillis() + )); + FriendData playerData = getFriendData(playerUuid); for (Friend friend : playerData.getFriends()) { @@ -348,6 +384,14 @@ public static void handlePlayerJoin(UUID playerUuid, String playerName) { } public static void handlePlayerLeave(UUID playerUuid, String playerName) { + PresenceStorage.upsert(new net.swofty.commons.presence.PresenceInfo( + playerUuid, + false, + null, + null, + System.currentTimeMillis() + )); + FriendData playerData = cachedFriendData.get(playerUuid); if (playerData == null) return; @@ -361,6 +405,69 @@ public static void handlePlayerLeave(UUID playerUuid, String playerName) { cachedFriendData.remove(playerUuid); } + public static String getPlayerName(UUID uuid) { + return resolvePlayerNames(List.of(uuid)).getOrDefault(uuid, "Unknown"); + } + + private static Map resolvePlayerNames(Collection uuids) { + Map names = new HashMap<>(); + if (uuids == null || uuids.isEmpty() || FriendDatabase.database == null) return names; + + List idStrings = uuids.stream().map(UUID::toString).toList(); + names.putAll(fetchNamesFromCollection("data", idStrings)); + + Set unresolved = new HashSet<>(uuids); + unresolved.removeAll(names.keySet()); + if (!unresolved.isEmpty()) { + List unresolvedIds = unresolved.stream().map(UUID::toString).toList(); + names.putAll(fetchNamesFromCollection("profiles", unresolvedIds)); + } + + return names; + } + + private static Map fetchNamesFromCollection(String collectionName, List ids) { + Map names = new HashMap<>(); + try { + MongoCollection collection = FriendDatabase.database.getCollection(collectionName); + if (collection == null || ids.isEmpty()) return names; + + for (Document doc : collection.find(Filters.in("_id", ids))) { + String id = doc.getString("_id"); + if (id == null) continue; + + String ign = parseStoredName(doc.getString("ign")); + if (ign == null && doc.containsKey("ignLowercase")) { + ign = parseStoredName(doc.getString("ignLowercase")); + } + + if (ign != null) { + names.put(UUID.fromString(id), ign); + } + } + } catch (Exception e) { + Logger.error(e, "Failed to resolve player names from collection {}", collectionName); + } + return names; + } + + private static String parseStoredName(String raw) { + if (raw == null) return null; + raw = raw.trim(); + if (raw.startsWith("\"") && raw.endsWith("\"") && raw.length() >= 2) { + raw = raw.substring(1, raw.length() - 1); + } + return raw.isEmpty() ? null : raw; + } + + private static String formatServerDisplay(net.swofty.commons.presence.PresenceInfo info) { + String type = info.getServerType(); + String id = info.getServerId(); + if (type == null && id == null) return null; + if (type != null && id != null) return type + " - " + id; + return type != null ? type : id; + } + private static void persistFriendData(UUID playerUuid) { FriendData data = cachedFriendData.get(playerUuid); if (data != null) { diff --git a/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java b/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java new file mode 100644 index 000000000..a71fcba2b --- /dev/null +++ b/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java @@ -0,0 +1,49 @@ +package net.swofty.service.friend; + +import net.swofty.commons.presence.PresenceInfo; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class PresenceStorage { + private static final Map presenceByUuid = new ConcurrentHashMap<>(); + + public static void upsert(PresenceInfo presence) { + presenceByUuid.put(presence.getUuid(), presence); + } + + public static List getBulk(Collection uuids) { + if (uuids == null || uuids.isEmpty()) return List.of(); + return uuids.stream() + .map(presenceByUuid::get) + .filter(java.util.Objects::nonNull) + .toList(); + } + + public static Map getMap(Collection uuids) { + if (uuids == null || uuids.isEmpty()) return Map.of(); + return uuids.stream() + .map(presenceByUuid::get) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toMap(PresenceInfo::getUuid, p -> p, (a, b) -> a)); + } + + public static PresenceInfo get(UUID uuid) { + return presenceByUuid.get(uuid); + } + + public static Map getOnlineStatus(Collection uuids) { + if (uuids == null || uuids.isEmpty()) return Map.of(); + return uuids.stream() + .collect(Collectors.toMap( + uuid -> uuid, + uuid -> presenceByUuid.getOrDefault(uuid, new PresenceInfo(uuid, false, null, null, System.currentTimeMillis())).isOnline(), + (a, b) -> a + )); + } +} + diff --git a/service.friend/src/main/java/net/swofty/service/friend/endpoints/FriendEventToServiceEndpoint.java b/service.friend/src/main/java/net/swofty/service/friend/endpoints/FriendEventToServiceEndpoint.java index 94ad551e5..d084e7732 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/endpoints/FriendEventToServiceEndpoint.java +++ b/service.friend/src/main/java/net/swofty/service/friend/endpoints/FriendEventToServiceEndpoint.java @@ -8,8 +8,6 @@ import net.swofty.service.generic.redis.ServiceEndpoint; import org.tinylog.Logger; -import java.util.HashMap; - public class FriendEventToServiceEndpoint implements ServiceEndpoint< SendFriendEventToServiceProtocolObject.SendFriendEventToServiceMessage, SendFriendEventToServiceProtocolObject.SendFriendEventToServiceResponse> { @@ -29,16 +27,37 @@ public SendFriendEventToServiceProtocolObject.SendFriendEventToServiceResponse o System.out.println("Received friend event: " + event.getClass().getSimpleName()); switch (event) { - case FriendAddRequestEvent e -> FriendCache.handleAddRequest(e, "Player", "Player"); - case FriendAcceptRequestEvent e -> FriendCache.handleAcceptRequest(e, "Player", "Player"); - case FriendDenyRequestEvent e -> FriendCache.handleDenyRequest(e, "Player"); - case FriendRemoveRequestEvent e -> FriendCache.handleRemoveRequest(e, "Player", "Player"); + case FriendAddRequestEvent e -> FriendCache.handleAddRequest( + e, + FriendCache.getPlayerName(e.getSender()), + FriendCache.getPlayerName(e.getTarget()) + ); + case FriendAcceptRequestEvent e -> FriendCache.handleAcceptRequest( + e, + FriendCache.getPlayerName(e.getAccepter()), + FriendCache.getPlayerName(e.getRequester()) + ); + case FriendDenyRequestEvent e -> FriendCache.handleDenyRequest( + e, + FriendCache.getPlayerName(e.getDenier()) + ); + case FriendRemoveRequestEvent e -> FriendCache.handleRemoveRequest( + e, + FriendCache.getPlayerName(e.getRemover()), + FriendCache.getPlayerName(e.getTarget()) + ); case FriendRemoveAllRequestEvent e -> FriendCache.handleRemoveAllRequest(e); - case FriendToggleBestRequestEvent e -> FriendCache.handleToggleBestRequest(e, "Player"); - case FriendSetNicknameRequestEvent e -> FriendCache.handleSetNicknameRequest(e, "Player"); + case FriendToggleBestRequestEvent e -> FriendCache.handleToggleBestRequest( + e, + FriendCache.getPlayerName(e.getTarget()) + ); + case FriendSetNicknameRequestEvent e -> FriendCache.handleSetNicknameRequest( + e, + FriendCache.getPlayerName(e.getTarget()) + ); case FriendToggleSettingRequestEvent e -> FriendCache.handleToggleSettingRequest(e); - case FriendListRequestEvent e -> FriendCache.handleListRequest(e, new HashMap<>(), new HashMap<>()); - case FriendRequestsListEvent e -> FriendCache.handleRequestsListRequest(e, new HashMap<>()); + case FriendListRequestEvent e -> FriendCache.handleListRequest(e); + case FriendRequestsListEvent e -> FriendCache.handleRequestsListRequest(e); default -> Logger.warn("Unknown friend event type: " + event.getClass().getSimpleName()); } diff --git a/service.friend/src/main/java/net/swofty/service/friend/endpoints/GetPresenceEndpoint.java b/service.friend/src/main/java/net/swofty/service/friend/endpoints/GetPresenceEndpoint.java new file mode 100644 index 000000000..925c5d2f4 --- /dev/null +++ b/service.friend/src/main/java/net/swofty/service/friend/endpoints/GetPresenceEndpoint.java @@ -0,0 +1,29 @@ +package net.swofty.service.friend.endpoints; + +import net.swofty.commons.impl.ServiceProxyRequest; +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.objects.presence.GetPresenceBulkProtocolObject; +import net.swofty.service.friend.PresenceStorage; +import net.swofty.service.generic.redis.ServiceEndpoint; + +import java.util.List; + +public class GetPresenceEndpoint implements ServiceEndpoint< + GetPresenceBulkProtocolObject.GetPresenceBulkMessage, + GetPresenceBulkProtocolObject.GetPresenceBulkResponse> { + + @Override + public GetPresenceBulkProtocolObject associatedProtocolObject() { + return new GetPresenceBulkProtocolObject(); + } + + @Override + public GetPresenceBulkProtocolObject.GetPresenceBulkResponse onMessage( + ServiceProxyRequest message, + GetPresenceBulkProtocolObject.GetPresenceBulkMessage messageObject) { + + List presence = PresenceStorage.getBulk(messageObject.uuids()); + return new GetPresenceBulkProtocolObject.GetPresenceBulkResponse(presence); + } +} + diff --git a/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java b/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java new file mode 100644 index 000000000..7352eba94 --- /dev/null +++ b/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java @@ -0,0 +1,43 @@ +package net.swofty.service.friend.endpoints; + +import net.swofty.commons.impl.ServiceProxyRequest; +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.objects.presence.UpdatePresenceProtocolObject; +import net.swofty.service.friend.FriendCache; +import net.swofty.service.friend.PresenceStorage; +import net.swofty.service.generic.redis.ServiceEndpoint; + +public class UpdatePresenceEndpoint implements ServiceEndpoint< + UpdatePresenceProtocolObject.UpdatePresenceMessage, + UpdatePresenceProtocolObject.UpdatePresenceResponse> { + + @Override + public UpdatePresenceProtocolObject associatedProtocolObject() { + return new UpdatePresenceProtocolObject(); + } + + @Override + public UpdatePresenceProtocolObject.UpdatePresenceResponse onMessage( + ServiceProxyRequest message, + UpdatePresenceProtocolObject.UpdatePresenceMessage messageObject) { + + PresenceInfo incoming = messageObject.presence(); + PresenceInfo previous = PresenceStorage.get(incoming.getUuid()); + + // Detect state change to trigger friend join/leave notifications + boolean stateChanged = previous == null || previous.isOnline() != incoming.isOnline(); + PresenceStorage.upsert(incoming); + + if (stateChanged) { + String playerName = FriendCache.getPlayerName(incoming.getUuid()); + if (incoming.isOnline()) { + FriendCache.handlePlayerJoin(incoming.getUuid(), playerName); + } else { + FriendCache.handlePlayerLeave(incoming.getUuid(), playerName); + } + } + + return new UpdatePresenceProtocolObject.UpdatePresenceResponse(true); + } +} + diff --git a/type.generic/src/main/java/net/swofty/type/generic/HypixelGenericLoader.java b/type.generic/src/main/java/net/swofty/type/generic/HypixelGenericLoader.java index 0433da43e..a6592204d 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/HypixelGenericLoader.java +++ b/type.generic/src/main/java/net/swofty/type/generic/HypixelGenericLoader.java @@ -165,6 +165,11 @@ public void initialize(MinecraftServer server) { */ loader.getTablistManager().runScheduler(MinecraftServer.getSchedulerManager()); + /** + * Presence heartbeat to keep friend status fresh + */ + net.swofty.type.generic.presence.PresenceHeartbeat.start(); + /** * Register databases */ diff --git a/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java b/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java index 0e2653b0e..178c0a141 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java +++ b/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java @@ -4,16 +4,17 @@ import net.minestom.server.command.builder.arguments.ArgumentStringArray; import net.minestom.server.command.builder.arguments.ArgumentType; import net.swofty.proxyapi.ProxyPlayer; +import net.swofty.type.generic.HypixelGenericLoader; import net.swofty.type.generic.command.CommandParameters; import net.swofty.type.generic.command.HypixelCommand; -import net.swofty.type.generic.data.DataHandler; +import net.swofty.type.generic.data.HypixelDataHandler; import net.swofty.type.generic.user.HypixelPlayer; import net.swofty.type.generic.user.categories.Rank; import org.jetbrains.annotations.Nullable; import java.util.UUID; -@CommandParameters(aliases = "msg", +@CommandParameters(aliases = "msg message whipser", description = "Sends a message to another player", usage = "/msg ", permission = Rank.DEFAULT, @@ -31,7 +32,7 @@ public void registerUsage(MinestomCommand command) { String[] message = context.get(messageArgument); HypixelPlayer player = (HypixelPlayer) sender; - @Nullable UUID targetUUID = DataHandler.getPotentialUUIDFromName(playerName); + @Nullable UUID targetUUID = resolveUUID(playerName); if (targetUUID == null) { player.sendMessage("§cCan't find a player by the name of '" + playerName + "'"); return; @@ -49,4 +50,15 @@ public void registerUsage(MinestomCommand command) { target.sendMessage("§dFrom " + ourName + "§7: " + String.join(" ", message)); }, playerArgument, messageArgument); } + + private @Nullable UUID resolveUUID(String name) { + // First, try online players (case-insensitive) + for (HypixelPlayer online : HypixelGenericLoader.getLoadedPlayers()) { + if (online.getUsername().equalsIgnoreCase(name)) { + return online.getUuid(); + } + } + // Fallback to stored data lookup + return HypixelDataHandler.getPotentialUUIDFromName(name); + } } diff --git a/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java b/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java new file mode 100644 index 000000000..8b200b7bf --- /dev/null +++ b/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java @@ -0,0 +1,57 @@ +package net.swofty.type.generic.presence; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.timer.TaskSchedule; +import net.swofty.commons.ServiceType; +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.objects.presence.UpdatePresenceProtocolObject; +import net.swofty.proxyapi.redis.ServerOutboundMessage; +import net.swofty.type.generic.HypixelConst; +import net.swofty.type.generic.HypixelGenericLoader; +import net.swofty.type.generic.user.HypixelPlayer; + +/** + * Periodically refreshes player presence to the friend service to avoid stale status. + */ +public final class PresenceHeartbeat { + private PresenceHeartbeat() {} + + public static void start() { + MinecraftServer.getSchedulerManager().buildTask(PresenceHeartbeat::pulse) + .delay(TaskSchedule.seconds(2)) + .repeat(TaskSchedule.seconds(10)) + .schedule(); + } + + private static void pulse() { + String serverType = null; + String serverId = null; + try { + serverType = HypixelConst.getTypeLoader().getType().name(); + if (HypixelConst.getServerUUID() != null) { + serverId = HypixelConst.getServerUUID().toString(); + } else if (HypixelConst.getServerName() != null) { + serverId = HypixelConst.getServerName(); + } + } catch (Exception ignored) { + } + + for (HypixelPlayer player : HypixelGenericLoader.getLoadedPlayers()) { + PresenceInfo info = new PresenceInfo( + player.getUuid(), + true, + serverType, + serverId, + System.currentTimeMillis() + ); + + ServerOutboundMessage.sendMessageToService( + ServiceType.FRIEND, + new UpdatePresenceProtocolObject(), + new UpdatePresenceProtocolObject.UpdatePresenceMessage(info), + (ignored) -> {} + ); + } + } +} + diff --git a/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java b/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java index 8d898348b..f547115cf 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java +++ b/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java @@ -224,14 +224,53 @@ private void handleFriendList(HypixelPlayer player, FriendListResponseEvent even sb.append("§6✦ "); } - // Name (with nickname if set) + // Name (rank-colored, matches tablist). Fallback to plain name if unavailable or failure. + String displayName; + try { + displayName = HypixelPlayer.getDisplayName(friend.getUuid()); + } catch (Exception e) { + displayName = "§e" + friend.getName(); + } + if (displayName == null || displayName.isEmpty()) { + displayName = "§e" + friend.getName(); + } + + // Append nickname if set if (friend.getNickname() != null && !friend.getNickname().isEmpty()) { - sb.append("§e").append(friend.getName()).append(" §7(").append(friend.getNickname()).append(")"); + sb.append(displayName).append(" §7(").append(friend.getNickname()).append(")"); } else { - sb.append("§e").append(friend.getName()); + sb.append(displayName); } - player.sendMessage(sb.toString()); + // Server info (when online) + if (friend.isOnline() && friend.getServer() != null && !friend.getServer().isEmpty()) { + sb.append(" §7- §e").append(friend.getServer()); + } + + TextComponent line = LegacyComponentSerializer.legacySection().deserialize(sb.toString()); + + String friendsSinceText; + if (friend.getFriendSince() > 0) { + long secondsSince = Math.max(0, (System.currentTimeMillis() - friend.getFriendSince()) / 1000); + friendsSinceText = "§7Friends for " + formatDuration(secondsSince); + } else { + friendsSinceText = "§7Friends since: Unknown"; + } + + TextComponent hovered; + if (friend.isOnline()) { + hovered = line.hoverEvent(Component.text(friendsSinceText)); + } else { + String lastSeenText; + if (friend.getLastSeen() > 0) { + long secondsAgo = Math.max(0, (System.currentTimeMillis() - friend.getLastSeen()) / 1000); + lastSeenText = "§7Last seen " + formatDuration(secondsAgo) + " ago"; + } else { + lastSeenText = "§7Last seen: Unknown"; + } + hovered = line.hoverEvent(Component.text(lastSeenText + "\n" + friendsSinceText)); + } + player.sendMessage(hovered); } } @@ -278,6 +317,22 @@ private void sendMessage(HypixelPlayer player, String message) { player.sendMessage("§9§m-----------------------------------------------------"); } + private String formatDuration(long seconds) { + long days = seconds / 86400; + long hours = (seconds % 86400) / 3600; + long minutes = (seconds % 3600) / 60; + if (days > 0) { + return days + "d " + hours + "h"; + } + if (hours > 0) { + return hours + "h " + minutes + "m"; + } + if (minutes > 0) { + return minutes + "m"; + } + return seconds + "s"; + } + private JSONObject createSuccessResponse(int playersHandled, List playersHandledUuids) { JSONObject response = new JSONObject(); response.put("success", true); diff --git a/velocity.extension/build.gradle.kts b/velocity.extension/build.gradle.kts index 9f0b8230b..d8ba4c113 100644 --- a/velocity.extension/build.gradle.kts +++ b/velocity.extension/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation("com.github.Swofty-Developments:AtlasRedisAPI:1.1.3") // implementation("net.swofty:AtlasRedisAPI:1.1.4") implementation(project(":commons")) + implementation(project(":proxy.api")) implementation("org.mongodb:bson:4.11.2") implementation("org.mongodb:mongodb-driver-sync:4.11.2") diff --git a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java index f139cd9b2..dc7c89bf8 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java @@ -12,6 +12,7 @@ import com.velocitypowered.api.event.permission.PermissionsSetupEvent; import com.velocitypowered.api.event.player.KickedFromServerEvent; import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent; +import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.event.proxy.ProxyPingEvent; import com.velocitypowered.api.permission.PermissionFunction; @@ -19,6 +20,7 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.proxy.server.ServerInfo; import com.velocitypowered.api.proxy.server.ServerPing; @@ -43,6 +45,7 @@ import net.swofty.velocity.gamemanager.BalanceConfigurations; import net.swofty.velocity.gamemanager.GameManager; import net.swofty.velocity.gamemanager.TransferHandler; +import net.swofty.velocity.presence.PresencePublisher; import net.swofty.velocity.packet.PlayerChannelHandler; import net.swofty.velocity.redis.ChannelListener; import net.swofty.velocity.redis.RedisListener; @@ -56,6 +59,7 @@ import java.lang.reflect.InvocationTargetException; import java.net.InetSocketAddress; import java.nio.file.Path; +import java.time.Duration; import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -114,6 +118,7 @@ public void onProxyInitialization(ProxyInitializeEvent event) { (AwaitingEventExecutor) postLoginEvent -> EventTask.withContinuation(continuation -> { injectPlayer(postLoginEvent.getPlayer()); TestFlowManager.handlePlayerJoin(postLoginEvent.getPlayer().getUsername()); + PresencePublisher.publish(postLoginEvent.getPlayer(), true, (String) null, null); continuation.resume(); })); server.getEventManager().register(this, PermissionsSetupEvent.class, @@ -128,14 +133,31 @@ public void onProxyInitialization(ProxyInitializeEvent event) { : EventTask.async(() -> { // Handle test flow player leave TestFlowManager.handlePlayerLeave(disconnectEvent.getPlayer().getUsername()); + PresencePublisher.publish(disconnectEvent.getPlayer(), false, (String) null, null); removePlayer(disconnectEvent.getPlayer()); }) ); + server.getEventManager().register(this, ServerConnectedEvent.class, + (AwaitingEventExecutor) serverConnectedEvent -> + EventTask.async(() -> { + RegisteredServer newServer = serverConnectedEvent.getServer(); + var type = GameManager.getTypeFromRegisteredServer(newServer); + PresencePublisher.publish(serverConnectedEvent.getPlayer(), true, newServer, type != null ? type.name() : null); + })); + + server.getScheduler().buildTask(SkyBlockVelocity.getPlugin(), () -> { + server.getAllPlayers().forEach(player -> { + var current = player.getCurrentServer(); + var type = current.map(conn -> GameManager.getTypeFromRegisteredServer(conn.getServer())).orElse(null); + PresencePublisher.publish(player, true, current.map(ServerConnection::getServer).orElse(null), + type != null ? type.name() : null); + }); + }).repeat(Duration.ofSeconds(10)).schedule(); + /** * Register commands */ - CommandManager commandManager = proxy.getCommandManager(); CommandMeta statusCommandMeta = commandManager.metaBuilder("serverstatus") .aliases("status") diff --git a/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java b/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java new file mode 100644 index 000000000..1916ae413 --- /dev/null +++ b/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java @@ -0,0 +1,42 @@ +package net.swofty.velocity.presence; + +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import net.swofty.commons.ServiceType; +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.objects.presence.UpdatePresenceProtocolObject; +import net.swofty.proxyapi.redis.ServerOutboundMessage; + +import java.util.UUID; + +public final class PresencePublisher { + + private PresencePublisher() {} + + public static void publish(Player player, boolean online, String serverType, UUID serverId) { + PresenceInfo info = new PresenceInfo( + player.getUniqueId(), + online, + serverType, + serverId != null ? serverId.toString() : null, + System.currentTimeMillis() + ); + + ServerOutboundMessage.sendMessageToAllServicesFireAndForget( + new UpdatePresenceProtocolObject(), + new UpdatePresenceProtocolObject.UpdatePresenceMessage(info) + ); + } + + public static void publish(Player player, boolean online, RegisteredServer server, String serverType) { + UUID serverId = null; + if (server != null) { + try { + serverId = UUID.fromString(server.getServerInfo().getName()); + } catch (Exception ignored) { + } + } + publish(player, online, serverType, serverId); + } +} + diff --git a/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java b/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java index 25c6b3945..7149e2168 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java @@ -14,6 +14,7 @@ import net.swofty.velocity.SkyBlockVelocity; import net.swofty.velocity.gamemanager.GameManager; import net.swofty.velocity.gamemanager.TransferHandler; +import net.swofty.velocity.presence.PresencePublisher; import net.swofty.velocity.redis.ChannelListener; import net.swofty.velocity.redis.RedisListener; import net.swofty.velocity.redis.RedisMessage; @@ -43,6 +44,8 @@ public JSONObject receivedMessage(JSONObject message, UUID serverUUID) { return new JSONObject(); } if (action == PlayerHandlerRequirements.PlayerHandlerActions.IS_ONLINE) { + Player player = potentialPlayer.get(); + publishPresence(player, true); return new JSONObject().put("isOnline", true); } Player player = potentialPlayer.get(); @@ -56,6 +59,7 @@ public JSONObject receivedMessage(JSONObject message, UUID serverUUID) { return new JSONObject(); } + publishPresence(player, true); return new JSONObject().put("server", new UnderstandableProxyServer( serverInfo.displayName(), serverInfo.internalID(), @@ -152,4 +156,14 @@ public JSONObject receivedMessage(JSONObject message, UUID serverUUID) { } return new JSONObject(); } + + private void publishPresence(Player player, boolean online) { + try { + Optional serverConn = player.getCurrentServer(); + var type = serverConn.map(conn -> GameManager.getTypeFromRegisteredServer(conn.getServer())).orElse(null); + PresencePublisher.publish(player, online, serverConn.map(ServerConnection::getServer).orElse(null), + type != null ? type.name() : null); + } catch (Exception ignored) { + } + } }