From 20d528d203c6df2c92b146f8176aafe79e999c31 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 16:25:08 +0000 Subject: [PATCH] feat(metadata): support metadata for user presence --- app/models/metadata_hub.py | 154 ++++++++++++++++++++++++++++++++++++ app/signalr/hub/metadata.py | 150 ++++++++++++++++++++++++++++++++++- 2 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 app/models/metadata_hub.py diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py new file mode 100644 index 0000000..24a29b5 --- /dev/null +++ b/app/models/metadata_hub.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from enum import IntEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class _UserActivity(BaseModel): + model_config = ConfigDict(serialize_by_alias=True) + type: Literal[ + "ChoosingBeatmap", + "InSoloGame", + "WatchingReplay", + "SpectatingUser", + "SearchingForLobby", + "InLobby", + "InMultiplayerGame", + "SpectatingMultiplayerGame", + "InPlaylistGame", + "EditingBeatmap", + "ModdingBeatmap", + "TestingBeatmap", + "InDailyChallengeLobby", + "PlayingDailyChallenge", + ] = Field(alias="$dtype") + value: Any | None = Field(alias="$value") + + +class ChoosingBeatmap(_UserActivity): + type: Literal["ChoosingBeatmap"] = "ChoosingBeatmap" + value: Literal[None] = None + + +class InGameValue(BaseModel): + beatmap_id: int = Field(alias="BeatmapID") + beatmap_display_title: str = Field(alias="BeatmapDisplayTitle") + ruleset_id: int = Field(alias="RulesetID") + ruleset_playing_verb: str = Field(alias="RulesetPlayingVerb") + + +class _InGame(_UserActivity): + value: InGameValue = Field(alias="$value") + + +class InSoloGame(_InGame): + type: Literal["InSoloGame"] = "InSoloGame" + + +class InMultiplayerGame(_InGame): + type: Literal["InMultiplayerGame"] = "InMultiplayerGame" + + +class SpectatingMultiplayerGame(_InGame): + type: Literal["SpectatingMultiplayerGame"] = "SpectatingMultiplayerGame" + + +class InPlaylistGame(_InGame): + type: Literal["InPlaylistGame"] = "InPlaylistGame" + + +class EditingBeatmapValue(BaseModel): + beatmap_id: int = Field(alias="BeatmapID") + beatmap_display_title: str = Field(alias="BeatmapDisplayTitle") + + +class EditingBeatmap(_UserActivity): + type: Literal["EditingBeatmap"] = "EditingBeatmap" + value: EditingBeatmapValue = Field(alias="$value") + + +class TestingBeatmap(_UserActivity): + type: Literal["TestingBeatmap"] = "TestingBeatmap" + + +class ModdingBeatmap(_UserActivity): + type: Literal["ModdingBeatmap"] = "ModdingBeatmap" + + +class WatchingReplayValue(BaseModel): + score_id: int = Field(alias="ScoreID") + player_name: str = Field(alias="PlayerName") + beatmap_id: int = Field(alias="BeatmapID") + beatmap_display_title: str = Field(alias="BeatmapDisplayTitle") + + +class WatchingReplay(_UserActivity): + type: Literal["WatchingReplay"] = "WatchingReplay" + value: int | None = Field(alias="$value") # Replay ID + + +class SpectatingUser(WatchingReplay): + type: Literal["SpectatingUser"] = "SpectatingUser" + + +class SearchingForLobby(_UserActivity): + type: Literal["SearchingForLobby"] = "SearchingForLobby" + value: None = Field(alias="$value") + + +class InLobbyValue(BaseModel): + room_id: int = Field(alias="RoomID") + room_name: str = Field(alias="RoomName") + + +class InLobby(_UserActivity): + type: Literal["InLobby"] = "InLobby" + value: None = Field(alias="$value") + + +class InDailyChallengeLobby(_UserActivity): + type: Literal["InDailyChallengeLobby"] = "InDailyChallengeLobby" + value: None = Field(alias="$value") + + +UserActivity = ( + ChoosingBeatmap + | InSoloGame + | WatchingReplay + | SpectatingUser + | SearchingForLobby + | InLobby + | InMultiplayerGame + | SpectatingMultiplayerGame + | InPlaylistGame + | EditingBeatmap + | ModdingBeatmap + | TestingBeatmap + | InDailyChallengeLobby +) + + +class MetadataClientState(BaseModel): + user_activity: UserActivity | None = None + status: OnlineStatus | None = None + + def to_dict(self) -> dict[str, Any] | None: + if self.status is None or self.status == OnlineStatus.OFFLINE: + return None + dumped = self.model_dump(by_alias=True, exclude_none=True) + return { + "Activity": dumped.get("user_activity"), + "Status": dumped.get("status"), + } + + @property + def pushable(self) -> bool: + return self.status is not None and self.status != OnlineStatus.OFFLINE + + +class OnlineStatus(IntEnum): + OFFLINE = 0 # 隐身 + DO_NOT_DISTURB = 1 + ONLINE = 2 diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index 325f77f..cfed09c 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -1,6 +1,152 @@ from __future__ import annotations -from .hub import Hub +import asyncio +from collections.abc import Coroutine + +from app.database.relationship import Relationship, RelationshipType +from app.dependencies.database import engine +from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity + +from .hub import Client, Hub + +from pydantic import TypeAdapter +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" -class MetadataHub(Hub): ... +class MetadataHub(Hub): + def __init__(self) -> None: + super().__init__() + self.state: dict[int, MetadataClientState] = {} + + @staticmethod + def online_presence_watchers_group() -> str: + return ONLINE_PRESENCE_WATCHERS_GROUP + + def broadcast_tasks( + self, user_id: int, store: MetadataClientState + ) -> set[Coroutine]: + if not store.pushable: + return set() + return { + self.broadcast_group_call( + self.online_presence_watchers_group(), + "UserPresenceUpdated", + user_id, + store.to_dict(), + ), + self.broadcast_group_call( + self.friend_presence_watchers_group(user_id), + "FriendPresenceUpdated", + user_id, + store.to_dict(), + ), + } + + @staticmethod + def friend_presence_watchers_group(user_id: int): + return f"metadata:friend-presence-watchers:{user_id}" + + async def on_client_connect(self, client: Client) -> None: + user_id = int(client.connection_id) + if store := self.state.get(user_id): + store = MetadataClientState() + self.state[user_id] = store + + async with AsyncSession(engine) as session: + async with session.begin(): + friends = ( + await session.exec( + select(Relationship.target_id).where( + Relationship.user_id == user_id, + Relationship.type == RelationshipType.FOLLOW, + ) + ) + ).all() + tasks = [] + for friend_id in friends: + self.groups.setdefault( + self.friend_presence_watchers_group(friend_id), set() + ).add(client) + if ( + friend_state := self.state.get(friend_id) + ) and friend_state.pushable: + tasks.append( + self.broadcast_group_call( + self.friend_presence_watchers_group(friend_id), + "FriendPresenceUpdated", + friend_id, + friend_state.to_dict(), + ) + ) + await asyncio.gather(*tasks) + + async def UpdateStatus(self, client: Client, status: int) -> None: + status_ = OnlineStatus(status) + user_id = int(client.connection_id) + store = self.state.get(user_id) + if store: + if store.status is not None and store.status == status_: + return + store.status = OnlineStatus(status_) + else: + store = MetadataClientState(status=OnlineStatus(status_)) + self.state[user_id] = store + tasks = self.broadcast_tasks(user_id, store) + tasks.add( + self.call_noblock( + client, + "UserPresenceUpdated", + user_id, + store.to_dict(), + ) + ) + await asyncio.gather(*tasks) + + async def UpdateActivity(self, client: Client, activity_dict: dict | None) -> None: + if activity_dict is None: + # idle + return + user_id = int(client.connection_id) + activity = TypeAdapter(UserActivity).validate_python(activity_dict) + store = self.state.get(user_id) + if store: + store.user_activity = activity + else: + store = MetadataClientState( + user_activity=activity, + ) + self.state[user_id] = store + + tasks = self.broadcast_tasks(user_id, store) + tasks.add( + self.call_noblock( + client, + "UserPresenceUpdated", + user_id, + store.to_dict(), + ) + ) + await asyncio.gather(*tasks) + + async def BeginWatchingUserPresence(self, client: Client) -> None: + await asyncio.gather( + *[ + self.call_noblock( + client, + "UserPresenceUpdated", + user_id, + store.to_dict(), + ) + for user_id, store in self.state.items() + if store.pushable + ] + ) + self.groups.setdefault(self.online_presence_watchers_group(), set()).add(client) + + async def EndWatchingUserPresence(self, client: Client) -> None: + self.groups.setdefault(self.online_presence_watchers_group(), set()).discard( + client + )