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>
255 lines
9.3 KiB
Python
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),
|
|
}
|