feat(metadata): support metadata for user presence
This commit is contained in:
154
app/models/metadata_hub.py
Normal file
154
app/models/metadata_hub.py
Normal 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
|
||||||
@@ -1,6 +1,152 @@
|
|||||||
from __future__ import annotations
|
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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user