"""Whispers between hunters — plain text, no attachments, no editing, no groups. Workstream S of docs/superpowers/specs/ 2026-07-30-hunters-and-messages-design.md. Security note, because it is the whole point of this module: every query is scoped to the caller's own user id. A "thread" is not a row anybody can address by id — it is derived from (sender_id, recipient_id) pairs where one side is always `user.id`, so there is no id a caller could guess to read somebody else's mail. See tests/test_messages.py's cross-user leak tests. """ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import case, desc, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_db from app.deps import get_current_user from app.models.message import BODY_MAX_CHARS, Message from app.models.user import User from app.routes.auth import WANDERER_PREFIX from app.rate_limit import RateLimiter from app.schemas import MessageIn router = APIRouter(prefix="/api/messages", tags=["messages"]) # Per-user, not per-IP: sending requires an account, so the user id is the # real actor. 20 an hour is plenty for conversation and useless for spam. send_limiter = RateLimiter(max_requests=20, window_seconds=3600) CONVERSATION_LIMIT = 50 THREAD_LIMIT_DEFAULT = 50 THREAD_LIMIT_MAX = 100 EXCERPT_CHARS = 160 AVATAR_FORMS = ("wisp", "banshee", "fairy", "shade") def _hunter_out(user: User) -> dict: """Public identity of a correspondent. Workstream P owns `display_name` / `avatar_form` / `avatar_hue` / `profile_public` and lands separately, so every one is read with `getattr(..., None)`: this module works identically before and after that merge, and the mailbox never depends on a profile being public — privacy hides the profile page, not the mailbox. """ form = getattr(user, "avatar_form", None) hue = getattr(user, "avatar_hue", None) return { "username": user.username, "display_name": getattr(user, "display_name", None) or user.username, "avatar": { "form": form if form in AVATAR_FORMS else "wisp", "hue": hue if isinstance(hue, int) and 0 <= hue <= 359 else 150, }, # False only when Workstream P exists AND the hunter opted out; the # UI uses this purely to decide whether to link to their profile. "profile_public": getattr(user, "profile_public", True) is not False, "is_wanderer": user.username.startswith(WANDERER_PREFIX), } def _message_out(message: Message, me_id) -> dict: return { "id": str(message.id), "body": message.body, "from_me": message.sender_id == me_id, "created_at": message.created_at.isoformat(), "read_at": message.read_at.isoformat() if message.read_at else None, } async def _unread_by_sender(db: AsyncSession, me_id) -> dict: """ONE aggregate query for every unread count — never a lookup per conversation. Keyed by the sender's user id.""" rows = await db.execute( select(Message.sender_id, func.count(Message.id)) .where(Message.recipient_id == me_id, Message.read_at.is_(None)) .group_by(Message.sender_id) ) return {sender_id: count for sender_id, count in rows} @router.post("", status_code=status.HTTP_201_CREATED) async def send_message( payload: MessageIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): # The one deliberate account-only capability besides device pairing: a # wanderer's name is temporary and unrecoverable, so a reply would have # nowhere to land. They can still RECEIVE. if user.username.startswith(WANDERER_PREFIX): raise HTTPException( status.HTTP_403_FORBIDDEN, "a wanderer has no name to sign — claim one before you whisper", ) body = payload.body.strip() if not body: raise HTTPException(status.HTTP_400_BAD_REQUEST, "an empty whisper carries nothing") if len(body) > BODY_MAX_CHARS: raise HTTPException( status.HTTP_400_BAD_REQUEST, f"the veil will not carry more than {BODY_MAX_CHARS} characters at once", ) to = payload.to.strip() if to.lower() == user.username.lower(): raise HTTPException( status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent" ) recipient = await db.scalar(select(User).where(User.username == to)) if recipient is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name") # Guard the id comparison too: the username check above is the friendly # path, this one is what actually makes self-messaging impossible. if recipient.id == user.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent" ) if not send_limiter.allow(str(user.id)): raise HTTPException( status.HTTP_429_TOO_MANY_REQUESTS, "you have whispered enough for one hour — let the veil settle", ) message = Message(sender_id=user.id, recipient_id=recipient.id, body=body) db.add(message) await db.commit() await db.refresh(message) return {"message": _message_out(message, user.id), "to": _hunter_out(recipient)} @router.get("") async def list_conversations( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Every correspondent, their last message excerpt, and unread counts. Two queries total regardless of how many conversations exist: one window-function pass for the newest message per correspondent, and one GROUP BY for unread counts. """ correspondent = case( (Message.sender_id == user.id, Message.recipient_id), else_=Message.sender_id ) ranked = ( select( Message.id, Message.sender_id, Message.body, Message.created_at, Message.read_at, correspondent.label("correspondent_id"), func.row_number() .over(partition_by=correspondent, order_by=desc(Message.created_at)) .label("rn"), ) .where(or_(Message.sender_id == user.id, Message.recipient_id == user.id)) .subquery() ) rows = ( await db.execute( select( ranked.c.sender_id, ranked.c.body, ranked.c.created_at, ranked.c.read_at, User, ) .join(User, User.id == ranked.c.correspondent_id) .where(ranked.c.rn == 1) .order_by(desc(ranked.c.created_at)) .limit(CONVERSATION_LIMIT) ) ).all() unread = await _unread_by_sender(db, user.id) conversations = [ { "hunter": _hunter_out(other), "excerpt": body[:EXCERPT_CHARS], "truncated": len(body) > EXCERPT_CHARS, "last_at": created_at.isoformat(), "last_from_me": sender_id == user.id, "unread": unread.get(other.id, 0), } for sender_id, body, created_at, read_at, other in rows ] return { "conversations": conversations, # Exposed here on purpose rather than on GET /api/profile/me, so the # badge does not depend on Workstream P. "unread_total": sum(unread.values()), } @router.get("/{username}") async def read_thread( username: str, limit: int = Query(default=THREAD_LIMIT_DEFAULT, ge=1, le=THREAD_LIMIT_MAX), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The thread with one hunter, newest LAST (reading order), capped at `limit` most-recent messages. Marks the caller's inbound messages read.""" other = await db.scalar(select(User).where(User.username == username)) if other is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name") if other.id == user.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent" ) # Both legs are pinned to (user.id, other.id) in both orders — no third # party's rows can match, whatever `username` is. scope = or_( (Message.sender_id == user.id) & (Message.recipient_id == other.id), (Message.sender_id == other.id) & (Message.recipient_id == user.id), ) total = await db.scalar(select(func.count(Message.id)).where(scope)) or 0 newest = ( await db.execute( select(Message).where(scope).order_by(desc(Message.created_at)).limit(limit) ) ).scalars().all() messages = list(reversed(newest)) # One UPDATE marks everything they sent us read — done after reading the # rows so the response still shows the read_at the caller had on open. await db.execute( update(Message) .where( Message.recipient_id == user.id, Message.sender_id == other.id, Message.read_at.is_(None), ) .values(read_at=datetime.now(timezone.utc)) ) await db.commit() return { "hunter": _hunter_out(other), "messages": [_message_out(m, user.id) for m in messages], "total": total, "has_more": total > len(messages), "can_send": not user.username.startswith(WANDERER_PREFIX), }