Files
Messenger/app/chats/service.py
benya e6a271f8be
Some checks failed
CI / test (push) Failing after 22s
feat(chat): add presence metadata and improve web chat core
- add user last_seen_at with alembic migration and persist on realtime disconnect
- extend chat serialization with private online/last_seen, group members/online, channel subscribers
- add Redis batch presence lookup helper
- update web chat list/header to display status counters and last-seen labels
- improve delivery receipt handling using last_delivered/last_read boundaries
- include chat info panel and related API/type updates
2026-03-08 02:02:09 +03:00

418 lines
17 KiB
Python

from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.chats import repository
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatRead, ChatTitleUpdateRequest
from app.messages.repository import (
delete_messages_in_chat,
get_hidden_message,
get_message_by_id,
hide_message_for_user,
list_chat_message_ids,
)
from app.realtime.presence import get_users_online_map
from app.users.repository import get_user_by_id
async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) -> ChatRead:
display_title = chat.title
members_count: int | None = None
online_count: int | None = None
subscribers_count: int | None = None
counterpart_user_id: int | None = None
counterpart_name: str | None = None
counterpart_username: str | None = None
counterpart_is_online: bool | None = None
counterpart_last_seen_at = None
if chat.is_saved:
display_title = "Saved Messages"
elif chat.type == ChatType.PRIVATE:
counterpart_id = await repository.get_private_counterpart_user_id(db, chat_id=chat.id, user_id=user_id)
if counterpart_id:
counterpart_user_id = counterpart_id
counterpart = await get_user_by_id(db, counterpart_id)
if counterpart:
display_title = counterpart.name or counterpart.username
counterpart_name = counterpart.name
counterpart_username = counterpart.username
counterpart_last_seen_at = counterpart.last_seen_at
presence = await get_users_online_map([counterpart_id])
counterpart_is_online = presence.get(counterpart_id, False)
else:
member_ids = await repository.list_chat_member_user_ids(db, chat_id=chat.id)
members_count = len(member_ids)
online_presence = await get_users_online_map(member_ids)
online_count = sum(1 for is_online in online_presence.values() if is_online)
if chat.type == ChatType.CHANNEL:
subscribers_count = members_count
unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id)
return ChatRead.model_validate(
{
"id": chat.id,
"type": chat.type,
"title": chat.title,
"display_title": display_title,
"handle": chat.handle,
"description": chat.description,
"is_public": chat.is_public,
"is_saved": chat.is_saved,
"unread_count": unread_count,
"pinned_message_id": chat.pinned_message_id,
"members_count": members_count,
"online_count": online_count,
"subscribers_count": subscribers_count,
"counterpart_user_id": counterpart_user_id,
"counterpart_name": counterpart_name,
"counterpart_username": counterpart_username,
"counterpart_is_online": counterpart_is_online,
"counterpart_last_seen_at": counterpart_last_seen_at,
"created_at": chat.created_at,
}
)
async def serialize_chats_for_user(db: AsyncSession, *, user_id: int, chats: list[Chat]) -> list[ChatRead]:
return [await serialize_chat_for_user(db, user_id=user_id, chat=chat) for chat in chats]
async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: ChatCreateRequest) -> Chat:
member_ids = list(dict.fromkeys(payload.member_ids))
member_ids = [member_id for member_id in member_ids if member_id != creator_id]
if payload.type == ChatType.PRIVATE and len(member_ids) != 1:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Private chat requires exactly one target user.",
)
if payload.type == ChatType.PRIVATE:
existing_chat = await repository.find_private_chat_between_users(
db,
user_a_id=creator_id,
user_b_id=member_ids[0],
)
if existing_chat:
return existing_chat
if payload.type in {ChatType.GROUP, ChatType.CHANNEL} and not payload.title:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Group and channel chats require title.",
)
if payload.type == ChatType.PRIVATE and payload.is_public:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Private chat cannot be public")
if payload.is_public and payload.type in {ChatType.GROUP, ChatType.CHANNEL} and not payload.handle:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Public chat requires handle")
if payload.handle:
existing = await repository.get_chat_by_handle(db, payload.handle.strip().lower())
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Handle is already taken")
for member_id in member_ids:
user = await get_user_by_id(db, member_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User {member_id} not found")
chat = await repository.create_chat_with_meta(
db,
chat_type=payload.type,
title=payload.title,
handle=payload.handle.strip().lower() if payload.handle else None,
description=payload.description,
is_public=payload.is_public,
is_saved=False,
)
await repository.add_chat_member(db, chat_id=chat.id, user_id=creator_id, role=ChatMemberRole.OWNER)
default_role = ChatMemberRole.MEMBER
for member_id in member_ids:
await repository.add_chat_member(db, chat_id=chat.id, user_id=member_id, role=default_role)
await db.commit()
return chat
async def get_chats_for_user(
db: AsyncSession,
*,
user_id: int,
limit: int = 50,
before_id: int | None = None,
query: str | None = None,
) -> list[Chat]:
safe_limit = max(1, min(limit, 100))
chats = await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query)
saved = await ensure_saved_messages_chat(db, user_id=user_id)
if saved.id not in [c.id for c in chats]:
chats = [saved, *chats]
else:
chats = [saved, *[c for c in chats if c.id != saved.id]]
return chats
async def get_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, list]:
chat = await repository.get_chat_by_id(db, chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
members = await repository.list_chat_members(db, chat_id=chat_id)
return chat, members
async def ensure_chat_membership(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
async def _get_chat_and_membership(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, ChatMember]:
chat = await repository.get_chat_by_id(db, chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat")
return chat, membership
def _ensure_manage_permission(role: ChatMemberRole) -> None:
if role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
def _ensure_group_or_channel(chat_type: ChatType) -> None:
if chat_type == ChatType.PRIVATE:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Private chat cannot be managed")
async def update_chat_title_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
payload: ChatTitleUpdateRequest,
) -> Chat:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
_ensure_group_or_channel(chat.type)
_ensure_manage_permission(membership.role)
chat.title = payload.title
await db.commit()
await db.refresh(chat)
return chat
async def add_chat_member_for_user(
db: AsyncSession,
*,
chat_id: int,
actor_user_id: int,
target_user_id: int,
) -> ChatMember:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
_ensure_group_or_channel(chat.type)
_ensure_manage_permission(membership.role)
if target_user_id == actor_user_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="User is already in chat")
target_user = await get_user_by_id(db, target_user_id)
if not target_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
existing = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
if existing:
return existing
try:
member = await repository.add_chat_member(
db,
chat_id=chat_id,
user_id=target_user_id,
role=ChatMemberRole.MEMBER,
)
await db.commit()
await db.refresh(member)
return member
except IntegrityError:
await db.rollback()
existing = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
if existing:
return existing
raise
async def update_chat_member_role_for_user(
db: AsyncSession,
*,
chat_id: int,
actor_user_id: int,
target_user_id: int,
role: ChatMemberRole,
) -> ChatMember:
chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
_ensure_group_or_channel(chat.type)
if actor_membership.role != ChatMemberRole.OWNER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only owner can change roles")
target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
if not target_membership:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target member not found")
if target_membership.role == ChatMemberRole.OWNER and target_user_id != actor_user_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot change owner role")
if target_user_id == actor_user_id and role != ChatMemberRole.OWNER:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot demote self")
target_membership.role = role
await db.commit()
await db.refresh(target_membership)
return target_membership
async def remove_chat_member_for_user(
db: AsyncSession,
*,
chat_id: int,
actor_user_id: int,
target_user_id: int,
) -> None:
chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
_ensure_group_or_channel(chat.type)
target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
if not target_membership:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target member not found")
if target_membership.role == ChatMemberRole.OWNER:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot be removed")
if actor_user_id == target_user_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Use leave endpoint")
if actor_membership.role == ChatMemberRole.ADMIN and target_membership.role != ChatMemberRole.MEMBER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin can remove only members")
if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
await repository.delete_chat_member(db, target_membership)
await db.commit()
async def leave_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
_ensure_group_or_channel(chat.type)
if membership.role == ChatMemberRole.OWNER:
members_count = await repository.count_chat_members(db, chat_id=chat_id)
if members_count > 1:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Owner cannot leave while chat has other members",
)
await repository.delete_chat_member(db, membership)
await db.commit()
async def pin_chat_message_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
payload: ChatPinMessageRequest,
) -> Chat:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
if chat.type in {ChatType.GROUP, ChatType.CHANNEL}:
_ensure_manage_permission(membership.role)
if payload.message_id is None:
chat.pinned_message_id = None
await db.commit()
await db.refresh(chat)
return chat
message = await get_message_by_id(db, payload.message_id)
if not message or message.chat_id != chat_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found in chat")
chat.pinned_message_id = message.id
await db.commit()
await db.refresh(chat)
return chat
async def ensure_saved_messages_chat(db: AsyncSession, *, user_id: int) -> Chat:
saved = await repository.find_saved_chat_for_user(db, user_id=user_id)
if saved:
return saved
chat = await repository.create_chat_with_meta(
db,
chat_type=ChatType.PRIVATE,
title="Saved Messages",
handle=None,
description="Personal cloud chat",
is_public=False,
is_saved=True,
)
await repository.add_chat_member(db, chat_id=chat.id, user_id=user_id, role=ChatMemberRole.OWNER)
await db.commit()
await db.refresh(chat)
return chat
async def discover_public_chats_for_user(db: AsyncSession, *, user_id: int, query: str | None, limit: int = 30) -> list[ChatDiscoverRead]:
rows = await repository.discover_public_chats(db, user_id=user_id, query=query, limit=max(1, min(limit, 50)))
return [
ChatDiscoverRead.model_validate(
{
"id": chat.id,
"type": chat.type,
"title": chat.title,
"handle": chat.handle,
"description": chat.description,
"is_public": chat.is_public,
"is_saved": chat.is_saved,
"pinned_message_id": chat.pinned_message_id,
"created_at": chat.created_at,
"is_member": is_member,
}
)
for chat, is_member in rows
]
async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> Chat:
chat = await repository.get_chat_by_id(db, chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if not chat.is_public or chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chat is not joinable")
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
if membership:
return chat
await repository.add_chat_member(db, chat_id=chat_id, user_id=user_id, role=ChatMemberRole.MEMBER)
await db.commit()
await db.refresh(chat)
return chat
async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int, payload: ChatDeleteRequest) -> None:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
if chat.is_saved:
await clear_chat_for_user(db, chat_id=chat_id, user_id=user_id)
return
delete_for_all = (payload.for_all and not chat.is_saved) or chat.type == ChatType.CHANNEL
if delete_for_all:
if chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
await db.delete(chat)
await db.commit()
return
await repository.delete_chat_member(db, membership)
await db.commit()
async def clear_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
chat, _membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
if chat.is_saved:
await delete_messages_in_chat(db, chat_id=chat_id)
await db.commit()
return
message_ids = await list_chat_message_ids(db, chat_id=chat_id)
for message_id in message_ids:
already_hidden = await get_hidden_message(db, message_id=message_id, user_id=user_id)
if already_hidden:
continue
await hide_message_for_user(db, message_id=message_id, user_id=user_id)
await db.commit()