feat(chat): support public channel chat

This commit is contained in:
MingxuanGame
2025-08-16 05:29:16 +00:00
parent 9a5c2fde08
commit f992e4cc71
13 changed files with 925 additions and 3 deletions

View File

@@ -10,6 +10,13 @@ from .beatmapset import (
BeatmapsetResp,
)
from .best_score import BestScore
from .chat import (
ChannelType,
ChatChannel,
ChatChannelResp,
ChatMessage,
ChatMessageResp,
)
from .counts import (
CountResp,
MonthlyPlaycounts,
@@ -63,6 +70,11 @@ __all__ = [
"Beatmapset",
"BeatmapsetResp",
"BestScore",
"ChannelType",
"ChatChannel",
"ChatChannelResp",
"ChatMessage",
"ChatMessageResp",
"CountResp",
"DailyChallengeStats",
"DailyChallengeStatsResp",

193
app/database/chat.py Normal file
View File

@@ -0,0 +1,193 @@
from datetime import UTC, datetime
from enum import Enum
from typing import Self
from app.database.lazer_user import RANKING_INCLUDES, User, UserResp
from app.models.model import UTCBaseModel
from pydantic import BaseModel
from redis.asyncio import Redis
from sqlmodel import (
VARCHAR,
BigInteger,
Column,
DateTime,
Field,
ForeignKey,
Relationship,
SQLModel,
select,
)
from sqlmodel.ext.asyncio.session import AsyncSession
# ChatChannel
class ChatUserAttributes(BaseModel):
can_message: bool
can_message_error: str | None = None
last_read_id: int
class ChannelType(str, Enum):
PUBLIC = "PUBLIC"
PRIVATE = "PRIVATE"
MULTIPLAYER = "MULTIPLAYER"
SPECTATOR = "SPECTATOR"
TEMPORARY = "TEMPORARY"
PM = "PM"
GROUP = "GROUP"
SYSTEM = "SYSTEM"
ANNOUNCE = "ANNOUNCE"
TEAM = "TEAM"
class ChatChannelBase(SQLModel):
name: str = Field(sa_column=Column(VARCHAR(50), index=True))
description: str = Field(sa_column=Column(VARCHAR(255), index=True))
icon: str | None = Field(default=None)
type: ChannelType = Field(index=True)
class ChatChannel(ChatChannelBase, table=True):
__tablename__ = "chat_channels" # pyright: ignore[reportAssignmentType]
channel_id: int | None = Field(primary_key=True, index=True, default=None)
@classmethod
async def get(
cls, channel: str | int, session: AsyncSession
) -> "ChatChannel | None":
if isinstance(channel, int) or channel.isdigit():
channel_ = await session.get(ChatChannel, channel)
if channel_ is not None:
return channel_
return (
await session.exec(select(ChatChannel).where(ChatChannel.name == channel))
).first()
class ChatChannelResp(ChatChannelBase):
channel_id: int
moderated: bool = False
uuid: str | None = None
current_user_attributes: ChatUserAttributes | None = None
last_read_id: int | None = None
last_message_id: int | None = None
recent_messages: list[str] | None = None
users: list[int] | None = None
message_length_limit: int = 1000
@classmethod
async def from_db(
cls,
channel: ChatChannel,
session: AsyncSession,
users: list[int],
user: User,
redis: Redis,
) -> Self:
c = cls.model_validate(channel)
silence = (
await session.exec(
select(SilenceUser).where(
SilenceUser.channel_id == channel.channel_id,
SilenceUser.user_id == user.id,
)
)
).first()
last_msg = await redis.get(f"chat:{channel.channel_id}:last_msg")
if last_msg and last_msg.isdigit():
last_msg = int(last_msg)
else:
last_msg = None
last_read_id = await redis.get(f"chat:{channel.channel_id}:last_read:{user.id}")
if last_read_id and last_read_id.isdigit():
last_read_id = int(last_read_id)
else:
last_read_id = last_msg
if silence is not None:
attribute = ChatUserAttributes(
can_message=False,
can_message_error=silence.reason or "You are muted in this channel.",
last_read_id=last_read_id or 0,
)
c.moderated = True
else:
attribute = ChatUserAttributes(
can_message=True,
last_read_id=last_read_id or 0,
)
c.moderated = False
c.current_user_attributes = attribute
c.users = users
c.last_message_id = last_msg
c.last_read_id = last_read_id
return c
# ChatMessage
class MessageType(str, Enum):
ACTION = "action"
MARKDOWN = "markdown"
PLAIN = "plain"
class ChatMessageBase(UTCBaseModel, SQLModel):
channel_id: int = Field(index=True, foreign_key="chat_channels.channel_id")
content: str = Field(sa_column=Column(VARCHAR(1000)))
message_id: int | None = Field(index=True, primary_key=True, default=None)
sender_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
)
timestamp: datetime = Field(
sa_column=Column(DateTime, index=True), default=datetime.now(UTC)
)
type: MessageType = Field(default=MessageType.PLAIN, index=True, exclude=True)
uuid: str | None = Field(default=None)
class ChatMessage(ChatMessageBase, table=True):
__tablename__ = "chat_messages" # pyright: ignore[reportAssignmentType]
user: User = Relationship(sa_relationship_kwargs={"lazy": "joined"})
class ChatMessageResp(ChatMessageBase):
sender: UserResp | None = None
is_action: bool = False
@classmethod
async def from_db(
cls, db_message: ChatMessage, session: AsyncSession, user: User | None = None
) -> "ChatMessageResp":
m = cls.model_validate(db_message.model_dump())
m.is_action = db_message.type == MessageType.ACTION
if user:
m.sender = await UserResp.from_db(user, session, RANKING_INCLUDES)
else:
m.sender = await UserResp.from_db(
db_message.user, session, RANKING_INCLUDES
)
return m
# SilenceUser
class SilenceUser(UTCBaseModel, SQLModel, table=True):
__tablename__ = "chat_silence_users" # pyright: ignore[reportAssignmentType]
id: int | None = Field(primary_key=True, default=None, index=True)
user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
)
channel_id: int = Field(foreign_key="chat_channels.channel_id", index=True)
until: datetime | None = Field(sa_column=Column(DateTime, index=True), default=None)
reason: str | None = Field(default=None, sa_column=Column(VARCHAR(255), index=True))
banned_at: datetime = Field(
sa_column=Column(DateTime, index=True), default=datetime.now(UTC)
)