Files
qtalker---/backend/app/routes/messages.py
Indiana bacfb852b8 feat: hunter profiles, ranks, whispers, and the encounter record
All five agents died mid-flight (three on session limits, two on 529s), but
their worktrees held real work — 17 files. Salvaged everything, wrote the
missing pieces, and finished the integration by hand.

PROFILES + RANK
User gains display_name, bio, gender, avatar_form, avatar_hue and
profile_public — all nullable, so every existing row including the guest
`wanderer-` accounts stays valid with no backfill. The avatar is procedural
(a GhostForm plus a hue, drawn by the same GhostGlyph that renders
entities): no uploads means no moderation surface, no EXIF and no blob
storage, and an `avatar_url` still slots in later without changing anything.

rank.py converts encounters, essence and favor into one "standing" currency
and maps it onto six one-word titles. An encounter is worth ten points to
ten essence's one, because contact is what the app is about — a seeker who
only buys unlocks climbs very slowly. Negative essence and favor floor at
zero rather than subtracting, so a bad judgment can never demote you: rank
is a record of what you have done. Level 1 costs exactly one encounter, so a
new hunter sees the bar move after their first séance.

Privacy invariants, verified live rather than assumed:
- `email` is returned by GET /api/profile/me and by nothing else. Confirmed
  against the running server: zero occurrences in both public payloads.
- A hidden profile 404s rather than 403s — confirming the account exists
  would leak exactly what hiding it was meant to prevent.

WHISPERS BETWEEN HUNTERS
Plain text, no attachments, no editing. Guests can RECEIVE but not send:
that gives registering a felt purpose beyond keeping a codex, and closes the
obvious spam vector since guest accounts are free and automatic. Verified
live: alice→bob delivers, a guest send returns 403, and a third party's
conversation list comes back empty — no cross-user leak.

Message bodies are rendered as text nodes, never as HTML, and wrap with
overflow-wrap:anywhere so a long unbroken string can't blow out the layout.

THE ENCOUNTER RECORD
The Codex already knew all of this — Entity.discovered_by has always been
recorded and every contact was already an entity_sightings row. Nobody ever
showed it. Now an entity page names its summoner and lists every hunter who
has met it. Hunters who opted out of a public profile are still COUNTED but
not linkable: an anonymous contact is still a contact, so a spirit's history
stays honest without exposing anyone.

Live on production data: Mabel Crump, discovered by Charly, 1 encounter;
Charly ranks channeler (level 2) from 5 real sightings — all computed from
data that was already sitting there.

385 frontend tests pass; i18n parity holds across both languages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:50:00 +00:00

255 lines
9.3 KiB
Python

"""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),
}