
Complete rewrite from task management to full-featured chat system: Core Features: - Real-time WebSocket messaging with connection management - Direct messages and group chats with admin controls - Message types: text, images, videos, audio, documents - Message status tracking: sent, delivered, read receipts - Typing indicators and user presence (online/offline) - Message replies, editing, and deletion Security & Encryption: - End-to-end encryption with RSA + AES hybrid approach - JWT authentication for API and WebSocket connections - Secure file storage with access control - Automatic RSA key pair generation per user Media & File Sharing: - Multi-format file upload (images, videos, audio, documents) - Automatic thumbnail generation for images/videos - File size validation and MIME type checking - Secure download endpoints with permission checks Notifications & Alerts: - Real-time WebSocket notifications - Push notifications via Firebase integration - @username mention alerts with notification history - Unread message and mention counting - Custom notification types (message, mention, group invite) Advanced Features: - Group chat management with roles (member, admin, owner) - User search and chat member management - Message pagination and chat history - Last seen timestamps and activity tracking - Comprehensive API documentation with WebSocket events Architecture: - Clean layered architecture with services, models, schemas - WebSocket connection manager for real-time features - Modular notification system with multiple channels - Comprehensive error handling and validation - Production-ready with Docker support Technologies: FastAPI, WebSocket, SQLAlchemy, SQLite, Cryptography, Firebase, Pillow
236 lines
7.9 KiB
Python
236 lines
7.9 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import desc
|
|
from app.db.session import get_db
|
|
from app.core.deps import get_current_active_user
|
|
from app.models import User, ChatMember as ChatMemberModel, Message as MessageModel
|
|
from app.schemas.message import Message, MessageUpdate, MessageList, MediaFile, MessageMention
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/{chat_id}", response_model=MessageList)
|
|
def get_chat_messages(
|
|
chat_id: int,
|
|
page: int = Query(1, ge=1),
|
|
limit: int = Query(50, ge=1, le=100),
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get messages for a chat with pagination"""
|
|
# Verify user is member of chat
|
|
membership = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == chat_id,
|
|
ChatMemberModel.user_id == current_user.id
|
|
).first()
|
|
|
|
if not membership:
|
|
raise HTTPException(status_code=404, detail="Chat not found")
|
|
|
|
# Calculate offset
|
|
offset = (page - 1) * limit
|
|
|
|
# Get total count
|
|
total = db.query(MessageModel).filter(
|
|
MessageModel.chat_id == chat_id,
|
|
MessageModel.is_deleted.is_(False)
|
|
).count()
|
|
|
|
# Get messages
|
|
messages_query = db.query(MessageModel).filter(
|
|
MessageModel.chat_id == chat_id,
|
|
MessageModel.is_deleted.is_(False)
|
|
).order_by(desc(MessageModel.created_at)).offset(offset).limit(limit)
|
|
|
|
db_messages = messages_query.all()
|
|
|
|
# Convert to response format
|
|
messages = []
|
|
for msg in db_messages:
|
|
# Get media files
|
|
media_files = []
|
|
for media in msg.media_files:
|
|
media_file = MediaFile(
|
|
id=media.id,
|
|
filename=media.filename,
|
|
original_filename=media.original_filename,
|
|
file_size=media.file_size,
|
|
mime_type=media.mime_type,
|
|
media_type=media.media_type.value,
|
|
width=media.width,
|
|
height=media.height,
|
|
duration=media.duration,
|
|
thumbnail_url=f"/api/media/{media.id}/thumbnail" if media.thumbnail_path else None
|
|
)
|
|
media_files.append(media_file)
|
|
|
|
# Get mentions
|
|
mentions = []
|
|
for mention in msg.mentions:
|
|
mention_data = MessageMention(
|
|
id=mention.mentioned_user.id,
|
|
username=mention.mentioned_user.username,
|
|
full_name=mention.mentioned_user.full_name
|
|
)
|
|
mentions.append(mention_data)
|
|
|
|
# Get reply-to message if exists
|
|
reply_to = None
|
|
if msg.reply_to:
|
|
reply_to = Message(
|
|
id=msg.reply_to.id,
|
|
chat_id=msg.reply_to.chat_id,
|
|
sender_id=msg.reply_to.sender_id,
|
|
content=msg.reply_to.content,
|
|
content_type=msg.reply_to.content_type,
|
|
status=msg.reply_to.status,
|
|
is_edited=msg.reply_to.is_edited,
|
|
is_deleted=msg.reply_to.is_deleted,
|
|
edited_at=msg.reply_to.edited_at,
|
|
created_at=msg.reply_to.created_at,
|
|
sender_username=msg.reply_to.sender.username,
|
|
sender_avatar=msg.reply_to.sender.avatar_url,
|
|
media_files=[],
|
|
mentions=[],
|
|
reply_to=None
|
|
)
|
|
|
|
message = Message(
|
|
id=msg.id,
|
|
chat_id=msg.chat_id,
|
|
sender_id=msg.sender_id,
|
|
content=msg.content,
|
|
content_type=msg.content_type,
|
|
reply_to_id=msg.reply_to_id,
|
|
status=msg.status,
|
|
is_edited=msg.is_edited,
|
|
is_deleted=msg.is_deleted,
|
|
edited_at=msg.edited_at,
|
|
created_at=msg.created_at,
|
|
sender_username=msg.sender.username,
|
|
sender_avatar=msg.sender.avatar_url,
|
|
media_files=media_files,
|
|
mentions=mentions,
|
|
reply_to=reply_to
|
|
)
|
|
messages.append(message)
|
|
|
|
# Reverse to show oldest first
|
|
messages.reverse()
|
|
|
|
return MessageList(
|
|
messages=messages,
|
|
total=total,
|
|
page=page,
|
|
has_more=offset + limit < total
|
|
)
|
|
|
|
@router.put("/{message_id}", response_model=Message)
|
|
def update_message(
|
|
message_id: int,
|
|
message_update: MessageUpdate,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update/edit a message (sender only)"""
|
|
message = db.query(MessageModel).filter(MessageModel.id == message_id).first()
|
|
|
|
if not message:
|
|
raise HTTPException(status_code=404, detail="Message not found")
|
|
|
|
if message.sender_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="Can only edit your own messages")
|
|
|
|
if message.is_deleted:
|
|
raise HTTPException(status_code=400, detail="Cannot edit deleted message")
|
|
|
|
# Update message
|
|
update_data = message_update.dict(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(message, field, value)
|
|
|
|
message.is_edited = True
|
|
from datetime import datetime
|
|
message.edited_at = datetime.utcnow()
|
|
|
|
db.commit()
|
|
db.refresh(message)
|
|
|
|
# Return updated message (simplified response)
|
|
return Message(
|
|
id=message.id,
|
|
chat_id=message.chat_id,
|
|
sender_id=message.sender_id,
|
|
content=message.content,
|
|
content_type=message.content_type,
|
|
reply_to_id=message.reply_to_id,
|
|
status=message.status,
|
|
is_edited=message.is_edited,
|
|
is_deleted=message.is_deleted,
|
|
edited_at=message.edited_at,
|
|
created_at=message.created_at,
|
|
sender_username=message.sender.username,
|
|
sender_avatar=message.sender.avatar_url,
|
|
media_files=[],
|
|
mentions=[],
|
|
reply_to=None
|
|
)
|
|
|
|
@router.delete("/{message_id}")
|
|
def delete_message(
|
|
message_id: int,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Delete a message (sender only or admin/owner)"""
|
|
message = db.query(MessageModel).filter(MessageModel.id == message_id).first()
|
|
|
|
if not message:
|
|
raise HTTPException(status_code=404, detail="Message not found")
|
|
|
|
# Check if user can delete (sender or admin/owner of chat)
|
|
can_delete = message.sender_id == current_user.id
|
|
|
|
if not can_delete:
|
|
# Check if user is admin/owner of the chat
|
|
from app.models.chat_member import MemberRole
|
|
membership = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == message.chat_id,
|
|
ChatMemberModel.user_id == current_user.id,
|
|
ChatMemberModel.role.in_([MemberRole.ADMIN, MemberRole.OWNER])
|
|
).first()
|
|
can_delete = membership is not None
|
|
|
|
if not can_delete:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
# Soft delete
|
|
message.is_deleted = True
|
|
message.content = "[Deleted]"
|
|
db.commit()
|
|
|
|
return {"message": "Message deleted successfully"}
|
|
|
|
@router.post("/{message_id}/read")
|
|
def mark_message_read(
|
|
message_id: int,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Mark message as read"""
|
|
message = db.query(MessageModel).filter(MessageModel.id == message_id).first()
|
|
|
|
if not message:
|
|
raise HTTPException(status_code=404, detail="Message not found")
|
|
|
|
# Update user's last read message
|
|
membership = db.query(ChatMemberModel).filter(
|
|
ChatMemberModel.chat_id == message.chat_id,
|
|
ChatMemberModel.user_id == current_user.id
|
|
).first()
|
|
|
|
if membership:
|
|
if not membership.last_read_message_id or membership.last_read_message_id < message.id:
|
|
membership.last_read_message_id = message.id
|
|
db.commit()
|
|
|
|
return {"message": "Message marked as read"} |