Files
Messenger/app/chats/service.py
benya db700bcbcd
All checks were successful
CI / test (push) Successful in 26s
moderation: add chat bans for groups/channels with web actions
2026-03-08 14:29:21 +03:00

705 lines
29 KiB
Python

import secrets
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,
ChatJoinByInviteRequest,
ChatNotificationSettingsRead,
ChatNotificationSettingsUpdate,
ChatInviteLinkRead,
ChatPinMessageRequest,
ChatProfileUpdateRequest,
ChatRead,
ChatTitleUpdateRequest,
)
from app.config.settings import settings
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, has_block_relation_between_users, is_user_in_contacts
from app.users.service import can_user_receive_private_messages
async def _can_view_last_seen(*, db: AsyncSession, target_user, viewer_user_id: int) -> bool:
if target_user.id == viewer_user_id:
return True
if target_user.privacy_last_seen == "everyone":
return True
if target_user.privacy_last_seen == "nobody":
return False
return await is_user_in_contacts(db, owner_user_id=target_user.id, candidate_user_id=viewer_user_id)
async def _can_view_avatar(*, db: AsyncSession, target_user, viewer_user_id: int) -> bool:
if target_user.id == viewer_user_id:
return True
if target_user.privacy_avatar == "everyone":
return True
if target_user.privacy_avatar == "nobody":
return False
return await is_user_in_contacts(db, owner_user_id=target_user.id, candidate_user_id=viewer_user_id)
async def _can_invite_to_group(*, db: AsyncSession, target_user, actor_user_id: int) -> bool:
if target_user.id == actor_user_id:
return False
if target_user.privacy_group_invites == "everyone":
return True
return await is_user_in_contacts(db, owner_user_id=target_user.id, candidate_user_id=actor_user_id)
async def serialize_chat_for_user(
db: AsyncSession,
*,
user_id: int,
chat: Chat,
current_username: str | None = None,
) -> ChatRead:
display_title = chat.title
my_role = None
membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id)
if membership:
my_role = membership.role
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_avatar_url: 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
presence_allowed = await _can_view_last_seen(db=db, target_user=counterpart, viewer_user_id=user_id)
counterpart_last_seen_at = counterpart.last_seen_at if presence_allowed else None
avatar_allowed = await _can_view_avatar(db=db, target_user=counterpart, viewer_user_id=user_id)
counterpart_avatar_url = counterpart.avatar_url if avatar_allowed else None
presence = await get_users_online_map([counterpart_id])
if counterpart:
presence_allowed = await _can_view_last_seen(db=db, target_user=counterpart, viewer_user_id=user_id)
counterpart_is_online = presence.get(counterpart_id, False) if presence_allowed else None
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
if not current_username:
current_user = await get_user_by_id(db, user_id)
current_username = current_user.username if current_user else None
unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id)
unread_mentions_count = await repository.get_unread_mentions_count_for_chat(
db,
chat_id=chat.id,
user_id=user_id,
username=current_username,
)
last_message = await repository.get_last_visible_message_for_user(db, chat_id=chat.id, user_id=user_id)
user_setting = await repository.get_chat_user_setting(db, chat_id=chat.id, user_id=user_id)
archived = bool(user_setting and user_setting.archived)
pinned = bool(user_setting and user_setting.pinned)
return ChatRead.model_validate(
{
"id": chat.id,
"public_id": chat.public_id,
"type": chat.type,
"title": chat.title,
"avatar_url": chat.avatar_url,
"display_title": display_title,
"handle": chat.handle,
"description": chat.description,
"is_public": chat.is_public,
"is_saved": chat.is_saved,
"archived": archived,
"pinned": pinned,
"unread_count": unread_count,
"unread_mentions_count": unread_mentions_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_avatar_url": counterpart_avatar_url,
"counterpart_is_online": counterpart_is_online,
"counterpart_last_seen_at": counterpart_last_seen_at,
"last_message_text": last_message.text if last_message else None,
"last_message_type": last_message.type if last_message else None,
"last_message_created_at": last_message.created_at if last_message else None,
"my_role": my_role,
"created_at": chat.created_at,
}
)
async def serialize_chats_for_user(db: AsyncSession, *, user_id: int, chats: list[Chat]) -> list[ChatRead]:
current_user = await get_user_by_id(db, user_id)
current_username = current_user.username if current_user else None
return [
await serialize_chat_for_user(db, user_id=user_id, chat=chat, current_username=current_username)
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:
target_user = await get_user_by_id(db, member_ids[0])
if target_user and not await can_user_receive_private_messages(db, target_user=target_user, actor_user_id=creator_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User does not accept private messages",
)
if await has_block_relation_between_users(db, user_a_id=creator_id, user_b_id=member_ids[0]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot create private chat due to block settings",
)
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")
if payload.type in {ChatType.GROUP, ChatType.CHANNEL}:
can_invite = await _can_invite_to_group(db=db, target_user=user, actor_user_id=creator_id)
if not can_invite:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"User {member_id} does not allow group invites from you",
)
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,
archived: bool = False,
) -> list[Chat]:
safe_limit = max(1, min(limit, 100))
if archived:
chats = await repository.list_archived_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id)
else:
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 update_chat_profile_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
payload: ChatProfileUpdateRequest,
) -> 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)
if payload.title is not None:
chat.title = payload.title.strip() or chat.title
if payload.description is not None:
chat.description = payload.description.strip() or None
if payload.avatar_url is not None:
chat.avatar_url = payload.avatar_url.strip() or None
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 await repository.is_user_banned_in_chat(db, chat_id=chat_id, user_id=target_user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is banned from this chat")
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")
if not await _can_invite_to_group(db=db, target_user=target_user, actor_user_id=actor_user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User does not allow group invites from you")
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 ban_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)
if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
if target_user_id == actor_user_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot ban yourself")
target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
if target_membership:
if target_membership.role == ChatMemberRole.OWNER:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot be banned")
if actor_membership.role == ChatMemberRole.ADMIN and target_membership.role != ChatMemberRole.MEMBER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin can ban only members")
await repository.delete_chat_member(db, target_membership)
await repository.upsert_chat_ban(db, chat_id=chat_id, user_id=target_user_id, banned_by_user_id=actor_user_id)
await db.commit()
async def unban_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)
if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
await repository.remove_chat_ban(db, chat_id=chat_id, user_id=target_user_id)
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,
"public_id": chat.public_id,
"type": chat.type,
"title": chat.title,
"avatar_url": chat.avatar_url,
"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")
if await repository.is_user_banned_in_chat(db, chat_id=chat_id, user_id=user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are banned from this chat")
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()
async def get_chat_notification_settings_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
) -> ChatNotificationSettingsRead:
await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id)
setting = await repository.get_chat_notification_setting(db, chat_id=chat_id, user_id=user_id)
return ChatNotificationSettingsRead(
chat_id=chat_id,
user_id=user_id,
muted=bool(setting and setting.muted),
)
async def update_chat_notification_settings_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
payload: ChatNotificationSettingsUpdate,
) -> ChatNotificationSettingsRead:
await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id)
setting = await repository.upsert_chat_notification_setting(
db,
chat_id=chat_id,
user_id=user_id,
muted=payload.muted,
)
await db.commit()
await db.refresh(setting)
return ChatNotificationSettingsRead(
chat_id=chat_id,
user_id=user_id,
muted=setting.muted,
)
async def set_chat_archived_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
archived: bool,
) -> Chat:
chat, _membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
await repository.upsert_chat_archived_setting(db, chat_id=chat_id, user_id=user_id, archived=archived)
await db.commit()
await db.refresh(chat)
return chat
async def set_chat_pinned_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
pinned: bool,
) -> Chat:
chat, _membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
await repository.upsert_chat_pinned_setting(db, chat_id=chat_id, user_id=user_id, pinned=pinned)
await db.commit()
await db.refresh(chat)
return chat
async def create_chat_invite_link_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
) -> ChatInviteLinkRead:
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)
token = secrets.token_urlsafe(18)
await repository.create_chat_invite_link(db, chat_id=chat_id, creator_user_id=user_id, token=token)
await db.commit()
invite_url = f"{settings.frontend_base_url.rstrip('/')}/join?token={token}"
return ChatInviteLinkRead(chat_id=chat_id, token=token, invite_url=invite_url)
async def join_chat_by_invite_for_user(
db: AsyncSession,
*,
user_id: int,
payload: ChatJoinByInviteRequest,
) -> Chat:
link = await repository.get_active_chat_invite_by_token(db, token=payload.token.strip())
if not link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite link not found")
chat = await repository.get_chat_by_id(db, link.chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite link is not valid for this chat")
if await repository.is_user_banned_in_chat(db, chat_id=chat.id, user_id=user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are banned from this chat")
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