feat(metadata): support metadata for user presence

This commit is contained in:
MingxuanGame
2025-07-27 16:25:08 +00:00
parent fb610c4898
commit 20d528d203
2 changed files with 302 additions and 2 deletions

154
app/models/metadata_hub.py Normal file
View File

@@ -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

View File

@@ -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
)