All checks were successful
CI / test (push) Successful in 9m2s
Security hardening: - Added IP/user rate limiting with Redis-backed counters and fail-open behavior. - Added message anti-spam controls (per-chat rate + duplicate cooldown). - Implemented refresh token rotation with JTI tracking and revoke support. Notification pipeline: - Added Celery app and async notification tasks for mention/offline delivery. - Added Redis-based presence tracking and integrated it into realtime connect/disconnect. - Added notification dispatch from message flow and notifications listing endpoint. Quality gates and CI: - Added pytest async integration tests for auth and chat/message lifecycle. - Added pytest config, test fixtures, and GitHub Actions CI workflow. - Fixed bcrypt/passlib compatibility by pinning bcrypt version. - Documented worker and quality-gate commands in README.
76 lines
2.9 KiB
Python
76 lines
2.9 KiB
Python
from fastapi import HTTPException, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.chats.service import ensure_chat_membership
|
|
from app.messages import repository
|
|
from app.messages.models import Message
|
|
from app.messages.spam_guard import enforce_message_spam_policy
|
|
from app.messages.schemas import MessageCreateRequest, MessageUpdateRequest
|
|
from app.notifications.service import dispatch_message_notifications
|
|
|
|
|
|
async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message:
|
|
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=sender_id)
|
|
if payload.type.value == "text" and not (payload.text and payload.text.strip()):
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Text message cannot be empty")
|
|
await enforce_message_spam_policy(user_id=sender_id, chat_id=payload.chat_id, text=payload.text)
|
|
|
|
message = await repository.create_message(
|
|
db,
|
|
chat_id=payload.chat_id,
|
|
sender_id=sender_id,
|
|
message_type=payload.type,
|
|
text=payload.text,
|
|
)
|
|
await db.commit()
|
|
await db.refresh(message)
|
|
try:
|
|
await dispatch_message_notifications(db, message)
|
|
except Exception:
|
|
# Notifications should not block message delivery.
|
|
pass
|
|
return message
|
|
|
|
|
|
async def get_messages(
|
|
db: AsyncSession,
|
|
*,
|
|
chat_id: int,
|
|
user_id: int,
|
|
limit: int = 50,
|
|
before_id: int | None = None,
|
|
) -> list[Message]:
|
|
await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id)
|
|
safe_limit = max(1, min(limit, 100))
|
|
return await repository.list_chat_messages(db, chat_id, limit=safe_limit, before_id=before_id)
|
|
|
|
|
|
async def update_message(
|
|
db: AsyncSession,
|
|
*,
|
|
message_id: int,
|
|
user_id: int,
|
|
payload: MessageUpdateRequest,
|
|
) -> Message:
|
|
message = await repository.get_message_by_id(db, message_id)
|
|
if not message:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
|
|
await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id)
|
|
if message.sender_id != user_id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can edit only your own messages")
|
|
message.text = payload.text
|
|
await db.commit()
|
|
await db.refresh(message)
|
|
return message
|
|
|
|
|
|
async def delete_message(db: AsyncSession, *, message_id: int, user_id: int) -> None:
|
|
message = await repository.get_message_by_id(db, message_id)
|
|
if not message:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
|
|
await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id)
|
|
if message.sender_id != user_id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can delete only your own messages")
|
|
await repository.delete_message(db, message)
|
|
await db.commit()
|