Add DM chat send/list endpoints: filter, rate limit, conversations (US-4)
Implements direct-message (DM) chat send and history endpoints:
- Migration 0011_add_dm_last_read.py (last_read_at on conversation_participants)
- Models updated in backend/app/models/chat.py
- Router in backend/app/routers/chat_dm.py:
- POST /api/chat/dm/{user_id}/messages (send DM, filtered via TextFilter, rate-limited via Redis)
- GET /api/chat/dm/{user_id}/messages (paginated history, updates last_read_at)
- GET /api/chat/dm/conversations (conversations list, last message, unread count)
- Blocks enforced in both directions (cannot send to/from blocked users)
- Self-messaging prevented, peer exist check, auth required
- Integration tests in backend/tests/test_chat_dm.py
Independently re-verified: 14/14 new DM chat tests pass,
272/272 full backend regression suite passes with zero regressions.
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"""Direct-message (DM) chat router.
|
||||
|
||||
docs/planning/social.md §4/§5 (US-4). Builds the send/list REST surface on
|
||||
top of ``app/models/chat.py`` (Conversation/ConversationParticipant/Message,
|
||||
already merged), ``app/services/text_filter.py`` (already merged), and
|
||||
``app/rate_limit.py`` (already merged).
|
||||
|
||||
Endpoints:
|
||||
- POST /api/chat/dm/{user_id}/messages -- send a DM to ``user_id``
|
||||
- GET /api/chat/dm/{user_id}/messages -- paginated history with ``user_id``
|
||||
- GET /api/chat/dm/conversations -- list the caller's conversations
|
||||
|
||||
Scope/boundaries (per task): REST send/list only, no WebSocket/real-time
|
||||
delivery (separate task), no world/room chat (separate sibling task; that
|
||||
uses the same ``Message`` table but with ``world_id``/``room_id`` set and
|
||||
``conversation_id`` null).
|
||||
|
||||
Design decisions this task owns:
|
||||
|
||||
- **Blocking.** A block in *either* direction (blocker->blockee or
|
||||
blockee->blocker) hard-prevents *sending* a new DM between the pair (403).
|
||||
It does not hide history the pair already has -- ``GET .../messages``
|
||||
still returns previously-exchanged messages after a block, consistent
|
||||
with the "preserve history" convention documented in
|
||||
``app/models/chat.py``'s module docstring. Only sending is blocked.
|
||||
- **Text filtering.** Every DM body is run through the module-level
|
||||
``TextFilter`` (the deterministic default, ``ListBasedTextFilter`` in
|
||||
``FilterMode.REDACT``) before storing:
|
||||
- ALLOW -> store the body as-is, ``filtered=False``.
|
||||
- REDACT -> store ``filtered_text`` (matched spans starred out),
|
||||
``filtered=True``.
|
||||
- REJECT -> nothing is stored; the sender gets a 422 explaining the
|
||||
message was rejected by the content filter. (The bundled default
|
||||
filter is configured in REDACT mode, so REJECT only fires for a
|
||||
deployment that swaps in a stricter-configured filter -- the handling
|
||||
is here regardless so that swap needs no router change.)
|
||||
- **Rate limiting.** Sending is capped via
|
||||
``rate_limit("chat_dm_send", max_hits=20, window_seconds=60)`` -- 20
|
||||
DM sends/minute/client-IP, generous for real chat use and painful for a
|
||||
spam bot. A no-op when ``settings.RATE_LIMIT_ENABLED`` is false (see
|
||||
``app/rate_limit.py``), matching every other rate-limited endpoint.
|
||||
- **Unread tracking.** A last-read timestamp per participant
|
||||
(``ConversationParticipant.last_read_at``, migration 0010). Fetching a
|
||||
conversation's *latest* page of history (``GET .../messages`` with no
|
||||
``before`` cursor) bumps the caller's own ``last_read_at`` to now; paging
|
||||
into older history does not. ``GET /conversations`` computes each
|
||||
conversation's ``unread_count`` as the number of messages from the peer
|
||||
(``sender_id != caller``) with ``created_at > last_read_at`` (or all of
|
||||
them, if the caller has never read the conversation).
|
||||
- **Pagination.**
|
||||
- History (``GET {user_id}/messages``): cursor-based, newest-first.
|
||||
``limit`` (default 50, max 100) plus an optional ``before`` cursor (a
|
||||
message id) that returns only messages older than that id. Response
|
||||
includes ``next_cursor`` (the oldest returned message's id, to pass as
|
||||
the next page's ``before``; null when there's no older history left).
|
||||
- Conversation list (``GET /conversations``): plain ``offset``/``limit``
|
||||
(default 20, max 100), ordered by ``Conversation.updated_at`` desc
|
||||
(bumped on every new message in that conversation, not just via the
|
||||
column's own ``onupdate`` -- see ``_touch_conversation`` below).
|
||||
- Both list endpoints are inherently scoped to the caller: the history
|
||||
endpoint only ever resolves the conversation between the caller and the
|
||||
path's ``user_id`` (no raw conversation id is ever accepted from the
|
||||
client), and the conversation list only ever queries
|
||||
``ConversationParticipant.user_id == caller``. There is no way for a
|
||||
client to address another pair's conversation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.chat import Conversation, ConversationParticipant, Message
|
||||
from app.models.social import Block
|
||||
from app.models.user import User
|
||||
from app.rate_limit import rate_limit
|
||||
from app.services.text_filter import FilterAction, default_text_filter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
_text_filter = default_text_filter()
|
||||
chat_dm_send_rate_limit = rate_limit("chat_dm_send", max_hits=20, window_seconds=60)
|
||||
|
||||
_BODY_MAX_LEN = 4000
|
||||
|
||||
# ──────────────────────────── Pydantic schemas ────────────────────────────
|
||||
|
||||
|
||||
class SendDMRequest(BaseModel):
|
||||
body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN)
|
||||
|
||||
|
||||
class DMMessageResponse(BaseModel):
|
||||
id: int
|
||||
conversation_id: int
|
||||
sender_id: int
|
||||
recipient_id: int
|
||||
body: str
|
||||
filtered: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DMMessageListResponse(BaseModel):
|
||||
messages: list[DMMessageResponse]
|
||||
next_cursor: int | None = None
|
||||
|
||||
|
||||
class DMConversationSummary(BaseModel):
|
||||
conversation_id: int
|
||||
peer_id: int
|
||||
peer_username: str
|
||||
last_message: DMMessageResponse | None
|
||||
unread_count: int
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DMConversationListResponse(BaseModel):
|
||||
conversations: list[DMConversationSummary]
|
||||
total: int
|
||||
|
||||
|
||||
# ──────────────────────────────── Helpers ──────────────────────────────────
|
||||
|
||||
|
||||
async def _get_target_user(db: AsyncSession, user_id: int) -> User:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
async def _is_blocked_either_direction(db: AsyncSession, user_a: int, user_b: int) -> bool:
|
||||
result = await db.execute(
|
||||
select(Block.id).where(
|
||||
or_(
|
||||
and_(Block.blocker_id == user_a, Block.blocked_id == user_b),
|
||||
and_(Block.blocker_id == user_b, Block.blocked_id == user_a),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _get_or_create_conversation(
|
||||
db: AsyncSession, user_a_id: int, user_b_id: int
|
||||
) -> Conversation:
|
||||
lo, hi = sorted((user_a_id, user_b_id))
|
||||
result = await db.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_a_id == lo, Conversation.user_b_id == hi
|
||||
)
|
||||
)
|
||||
conversation = result.scalar_one_or_none()
|
||||
if conversation is not None:
|
||||
return conversation
|
||||
|
||||
conversation = Conversation(user_a_id=lo, user_b_id=hi)
|
||||
db.add(conversation)
|
||||
await db.flush() # assign conversation.id
|
||||
db.add(ConversationParticipant(conversation_id=conversation.id, user_id=lo))
|
||||
db.add(ConversationParticipant(conversation_id=conversation.id, user_id=hi))
|
||||
await db.flush()
|
||||
return conversation
|
||||
|
||||
|
||||
async def _get_participant(
|
||||
db: AsyncSession, conversation_id: int, user_id: int
|
||||
) -> ConversationParticipant | None:
|
||||
result = await db.execute(
|
||||
select(ConversationParticipant).where(
|
||||
ConversationParticipant.conversation_id == conversation_id,
|
||||
ConversationParticipant.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _peer_id(conversation: Conversation, self_id: int) -> int:
|
||||
return conversation.user_b_id if conversation.user_a_id == self_id else conversation.user_a_id
|
||||
|
||||
|
||||
def _touch_conversation(conversation: Conversation) -> None:
|
||||
"""Bump ``updated_at`` explicitly.
|
||||
|
||||
``Conversation.updated_at`` has ``onupdate=func.now()``, but that only
|
||||
fires when an UPDATE statement touches the ``conversations`` row itself
|
||||
-- inserting a ``Message`` row alone never does. Explicitly dirtying the
|
||||
column here is what actually keeps "most recently active conversation"
|
||||
ordering correct for ``GET /conversations``.
|
||||
"""
|
||||
conversation.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _to_message_response(message: Message, recipient_id: int) -> DMMessageResponse:
|
||||
return DMMessageResponse(
|
||||
id=message.id,
|
||||
conversation_id=message.conversation_id,
|
||||
sender_id=message.sender_id,
|
||||
recipient_id=recipient_id,
|
||||
body=message.body,
|
||||
filtered=message.filtered,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────── Endpoints ────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/messages",
|
||||
response_model=DMMessageResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(chat_dm_send_rate_limit)],
|
||||
)
|
||||
async def send_dm(
|
||||
user_id: int,
|
||||
body: SendDMRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Send a DM to ``user_id``."""
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a DM to yourself"
|
||||
)
|
||||
|
||||
await _get_target_user(db, user_id)
|
||||
|
||||
if await _is_blocked_either_direction(db, current_user.id, user_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot message this user",
|
||||
)
|
||||
|
||||
result = _text_filter.check(body.body)
|
||||
if result.action is FilterAction.REJECT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Message rejected by the content filter.",
|
||||
)
|
||||
|
||||
conversation = await _get_or_create_conversation(db, current_user.id, user_id)
|
||||
|
||||
message = Message(
|
||||
sender_id=current_user.id,
|
||||
conversation_id=conversation.id,
|
||||
body=result.filtered_text,
|
||||
filtered=(result.action is FilterAction.REDACT),
|
||||
)
|
||||
db.add(message)
|
||||
_touch_conversation(conversation)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
return _to_message_response(message, recipient_id=user_id)
|
||||
|
||||
|
||||
@router.get("/{user_id}/messages", response_model=DMMessageListResponse)
|
||||
async def get_dm_history(
|
||||
user_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
before: int | None = Query(
|
||||
None, description="Return messages older than this message id (pagination cursor)."
|
||||
),
|
||||
):
|
||||
"""Paginated DM history between the caller and ``user_id``, newest first.
|
||||
|
||||
Marks the conversation as read (bumps the caller's ``last_read_at``) only
|
||||
when fetching the first page (``before`` omitted) -- paging into older
|
||||
history doesn't affect unread state.
|
||||
"""
|
||||
await _get_target_user(db, user_id)
|
||||
|
||||
lo, hi = sorted((current_user.id, user_id))
|
||||
conv_result = await db.execute(
|
||||
select(Conversation).where(Conversation.user_a_id == lo, Conversation.user_b_id == hi)
|
||||
)
|
||||
conversation = conv_result.scalar_one_or_none()
|
||||
if conversation is None:
|
||||
# No conversation yet -- nothing exchanged, not an error.
|
||||
return DMMessageListResponse(messages=[], next_cursor=None)
|
||||
|
||||
stmt = select(Message).where(Message.conversation_id == conversation.id)
|
||||
if before is not None:
|
||||
stmt = stmt.where(Message.id < before)
|
||||
stmt = stmt.order_by(Message.id.desc()).limit(limit + 1)
|
||||
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
has_more = len(rows) > limit
|
||||
page = rows[:limit]
|
||||
next_cursor = page[-1].id if has_more and page else None
|
||||
|
||||
if before is None:
|
||||
participant = await _get_participant(db, conversation.id, current_user.id)
|
||||
if participant is not None:
|
||||
participant.last_read_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
return DMMessageListResponse(
|
||||
messages=[_to_message_response(m, recipient_id=user_id) for m in page],
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=DMConversationListResponse)
|
||||
async def list_conversations(
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List the caller's DM conversations, most recently active first."""
|
||||
total_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(ConversationParticipant)
|
||||
.where(ConversationParticipant.user_id == current_user.id)
|
||||
)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
stmt = (
|
||||
select(Conversation, ConversationParticipant)
|
||||
.join(
|
||||
ConversationParticipant,
|
||||
ConversationParticipant.conversation_id == Conversation.id,
|
||||
)
|
||||
.where(ConversationParticipant.user_id == current_user.id)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
summaries: list[DMConversationSummary] = []
|
||||
for conversation, participant in rows:
|
||||
peer_id = _peer_id(conversation, current_user.id)
|
||||
|
||||
peer = (
|
||||
await db.execute(select(User).where(User.id == peer_id))
|
||||
).scalar_one_or_none()
|
||||
peer_username = peer.username if peer is not None else "(deleted user)"
|
||||
|
||||
last_message_row = (
|
||||
await db.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation.id)
|
||||
.order_by(Message.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
last_message = (
|
||||
_to_message_response(
|
||||
last_message_row,
|
||||
recipient_id=(
|
||||
peer_id if last_message_row.sender_id == current_user.id else current_user.id
|
||||
),
|
||||
)
|
||||
if last_message_row is not None
|
||||
else None
|
||||
)
|
||||
|
||||
unread_filters = [
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.sender_id != current_user.id,
|
||||
]
|
||||
if participant.last_read_at is not None:
|
||||
unread_filters.append(Message.created_at > participant.last_read_at)
|
||||
unread_count = (
|
||||
await db.execute(select(func.count()).select_from(Message).where(*unread_filters))
|
||||
).scalar_one()
|
||||
|
||||
summaries.append(
|
||||
DMConversationSummary(
|
||||
conversation_id=conversation.id,
|
||||
peer_id=peer_id,
|
||||
peer_username=peer_username,
|
||||
last_message=last_message,
|
||||
unread_count=unread_count,
|
||||
updated_at=conversation.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
return DMConversationListResponse(conversations=summaries, total=total)
|
||||
Reference in New Issue
Block a user