Compare commits

...

7 Commits

Author SHA1 Message Date
coder-bot 47ab492231 Fix StatusUpdate field annotation and is_online batch-call mismatch
- status_message: str | Field(...) is invalid (Field belongs on the
  default, not the type union) -> str | None = Field(...)
- router called is_online(ids) with a list and treated the bool result
  as a collection (uid in online_ids would TypeError on the first real
  call) -> added is_online_batch() using MGET, one round trip, tested
  against a live Redis instance
2026-08-18 16:46:52 +00:00
coder-bot 751bd11ed0 WIP checkpoint: Presence & status model + endpoints (Redis heartbeat) 2026-08-18 16:41:56 +00:00
coder-bot 0abae90892 WIP checkpoint: Presence & status model + endpoints (Redis heartbeat) 2026-08-18 15:59:27 +00:00
coder-bot 024a200315 WIP checkpoint: Presence & status model + endpoints (Redis heartbeat) 2026-08-18 15:58:35 +00:00
reviewer-bot a9947a0a80 Merge pull request 'Plan: Social — Friends, Chat & Groups' (#21) from agent/5b8c6bf79631 into main 2026-08-18 14:56:28 +00:00
reviewer-bot 99762887be Merge pull request 'Address review feedback: Implement chunk streaming system in GDScript' (#20) from agent/bd718b31b595 into main 2026-08-18 14:41:08 +00:00
planner-bot 1061dc0742 WIP checkpoint: Plan: Social — Friends, Chat & Groups 2026-08-18 14:40:30 +00:00
7 changed files with 557 additions and 2 deletions
@@ -0,0 +1,29 @@
"""add presence columns to users
Revision ID: 2a9b9873244a
Revises: 0001_initial
Create Date: 2026-08-18 15:59:09.298919
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2a9b9873244a'
down_revision: Union[str, None] = '0001_initial'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add last_seen_at and status_message columns to users table."""
op.add_column('users', sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('users', sa.Column('status_message', sa.Text(), nullable=True))
def downgrade() -> None:
"""Remove presence columns from users table."""
op.drop_column('users', 'status_message')
op.drop_column('users', 'last_seen_at')
+2 -1
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import auth, users, worlds, assets from app.routers import auth, users, worlds, assets, presence
@asynccontextmanager @asynccontextmanager
@@ -38,6 +38,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"]) app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"]) app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
@app.get("/api/health") @app.get("/api/health")
+5 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -16,6 +16,10 @@ class User(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Presence / status
last_seen_at = Column(DateTime(timezone=True), nullable=True)
status_message = Column(Text, nullable=True)
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner") worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner") assets = relationship("Asset", back_populates="owner")
+155
View File
@@ -0,0 +1,155 @@
"""Presence & status router.
Endpoints:
- POST /api/presence/heartbeat – record a heartbeat (sets Redis TTL key)
- GET /api/presence/presence – query presence for a list of user ids
- PATCH /api/presence/status – update the authenticated user's status message
"""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.user import User
from app.services.presence import PresenceStore
router = APIRouter()
# ──────────────────────────── Pydantic schemas ────────────────────────────────
_STATUS_MAX_LEN = 140
class HeartbeatRequest(BaseModel):
"""Empty body – the caller's identity comes from the JWT."""
class PresenceUser(BaseModel):
user_id: int
is_online: bool
last_seen_at: datetime | None
status_message: str | None
class PresenceResponse(BaseModel):
users: list[PresenceUser]
class StatusUpdate(BaseModel):
status_message: str | None = Field(default=None, max_length=_STATUS_MAX_LEN)
@field_validator("status_message")
@classmethod
def strip_message(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
return v
# ──────────────────────────────── Endpoints ───────────────────────────────────
# The presence store is created once (singleton) so it shares the same Redis
# connection pool across all requests.
_presence_store = PresenceStore()
@router.post("/heartbeat", response_model=dict)
async def heartbeat(
_req: HeartbeatRequest,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Record a heartbeat for the authenticated user.
Sets a Redis key with a TTL so the server can derive online/offline
status without trusting client claims. Also persists last_seen_at to
the database.
"""
# Update Redis TTL (presence heartbeat)
await _presence_store.heartbeat(current_user.id)
# Persist last_seen_at to the database
now = datetime.now(timezone.utc)
result = await db.execute(
select(User).where(User.id == current_user.id)
)
user = result.scalar_one_or_none()
if user:
user.last_seen_at = now
await db.commit()
return {"status": "ok", "user_id": current_user.id}
@router.get("/presence", response_model=PresenceResponse)
async def get_presence(
user_ids: str,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Return presence info for a comma-separated list of user ids.
Online/offline is derived from the Redis heartbeat TTL.
last_seen_at and status_message come from the database.
"""
ids = [int(uid.strip()) for uid in user_ids.split(",") if uid.strip()]
if not ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="user_ids must contain at least one integer",
)
# Check Redis for online status
online_ids = await _presence_store.is_online_batch(ids)
# Fetch DB records for last_seen_at and status_message
result = await db.execute(
select(User).where(User.id.in_(ids))
)
users = {u.id: u for u in result.scalars().all()}
presence_users = []
for uid in ids:
user = users.get(uid)
if user is None:
continue
presence_users.append(
PresenceUser(
user_id=uid,
is_online=uid in online_ids,
last_seen_at=user.last_seen_at,
status_message=user.status_message,
)
)
return PresenceResponse(users=presence_users)
@router.patch("/status", response_model=dict)
async def update_status(
body: StatusUpdate,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update the authenticated user's status message (length-limited)."""
result = await db.execute(
select(User).where(User.id == current_user.id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user.status_message = body.status_message
await db.commit()
return {"status": "ok", "status_message": body.status_message}
View File
+196
View File
@@ -0,0 +1,196 @@
"""Redis-backed presence store.
Presence is determined by a heartbeat TTL key in Redis:
- heartbeat(key) sets a TTL key per user (default 60 s).
- is_online(user_id) checks whether the key exists.
- get_presence(user_ids) returns online/offline + last_seen + status_message
for a list of user ids.
The status_message is also persisted in the users table and updated via
update_status_message().
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
# Key prefix for heartbeat entries
_PRESENCE_KEY_PREFIX = "presence:heartbeat:"
# Default heartbeat TTL in seconds (1 minute).
# Clients should heartbeat at least once per interval.
_DEFAULT_HEARTBEAT_TTL = 60
# Maximum length for status messages (characters).
_MAX_STATUS_MESSAGE_LENGTH = 200
class PresenceStore:
"""Redis-backed presence store with async operations."""
def __init__(self, redis_url: str = settings.REDIS_URL, heartbeat_ttl: int = _DEFAULT_HEARTBEAT_TTL):
self._redis_url = redis_url
self._heartbeat_ttl = heartbeat_ttl
self._client: aioredis.Redis | None = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def connect(self) -> None:
"""Connect to Redis."""
self._client = aioredis.from_url(
self._redis_url,
encoding="utf-8",
decode_responses=True,
)
# Verify connectivity
try:
await self._client.ping()
except Exception as exc:
logger.warning("Redis ping failed: %s", exc)
raise
async def close(self) -> None:
"""Close the Redis connection."""
if self._client is not None:
await self._client.close()
self._client = None
@property
def client(self) -> aioredis.Redis:
"""Return the Redis client, raising if not connected."""
if self._client is None:
raise RuntimeError("PresenceStore is not connected. Call connect() first.")
return self._client
# ------------------------------------------------------------------
# Heartbeat
# ------------------------------------------------------------------
async def heartbeat(self, user_id: int) -> None:
"""Record a heartbeat for *user_id*.
Sets a TTL key so that the user is considered online as long as
heartbeats arrive at least once per heartbeat_ttl seconds.
"""
key = self._heartbeat_key(user_id)
await self.client.set(key, "1", ex=self._heartbeat_ttl)
# ------------------------------------------------------------------
# Online check
# ------------------------------------------------------------------
async def is_online(self, user_id: int) -> bool:
"""Return True if the user has a valid heartbeat key."""
key = self._heartbeat_key(user_id)
exists = await self.client.exists(key)
return bool(exists)
async def is_online_batch(self, user_ids: list[int]) -> set[int]:
"""Return the subset of user_ids that currently have a valid
heartbeat key. Uses MGET (one round trip) rather than one EXISTS
per user."""
if not user_ids:
return set()
keys = [self._heartbeat_key(uid) for uid in user_ids]
values = await self.client.mget(keys)
return {uid for uid, v in zip(user_ids, values) if v is not None}
# ------------------------------------------------------------------
# Presence lookup
# ------------------------------------------------------------------
async def get_presence(
self,
user_ids: list[int],
db: Any, # AsyncSession — imported here to avoid circular imports
) -> dict[int, dict[str, Any]]:
"""Return presence info for a list of user ids.
Returns a dict keyed by user_id with:
online: bool
last_seen_at: datetime | None
status_message: str | None
"""
from app.models.user import User
from sqlalchemy import select
result = await db.execute(select(User).where(User.id.in_(user_ids)))
users = result.scalars().all()
user_map = {u.id: u for u in users}
presence: dict[int, dict[str, Any]] = {}
for uid in user_ids:
user = user_map.get(uid)
online = await self.is_online(uid) if user else False
presence[uid] = {
"user_id": uid,
"online": online,
"last_seen_at": user.last_seen_at if user else None,
"status_message": user.status_message if user else None,
}
return presence
# ------------------------------------------------------------------
# Status message
# ------------------------------------------------------------------
async def update_status_message(
self,
user_id: int,
message: str | None,
db: Any, # AsyncSession
) -> None:
"""Update the status_message for *user_id* in the database."""
from app.models.user import User
from sqlalchemy import select
if message is not None and len(message) > _MAX_STATUS_MESSAGE_LENGTH:
raise ValueError(
f"Status message exceeds maximum length of {_MAX_STATUS_MESSAGE_LENGTH} characters"
)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise ValueError(f"User {user_id} not found")
user.status_message = message
user.last_seen_at = datetime.now(timezone.utc)
await db.commit()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _heartbeat_key(user_id: int) -> str:
return f"{_PRESENCE_KEY_PREFIX}{user_id}"
# Singleton instance — initialised by the lifespan handler
presence_store: PresenceStore | None = None
async def init_presence_store() -> None:
"""Create and connect the global presence store."""
global presence_store
presence_store = PresenceStore()
await presence_store.connect()
async def shutdown_presence_store() -> None:
"""Close the global presence store."""
global presence_store
if presence_store is not None:
await presence_store.close()
presence_store = None
+170
View File
@@ -0,0 +1,170 @@
# Social — Friends, Chat & Groups — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `social`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
## 1. Overview
This document breaks the **Social** feature into user stories and right-sized
implementation tasks. The feature delivers:
- **Friends & following** — a friend request/accept model plus a one-way follow
relationship, with block lists.
- **Presence & status** — online/offline, custom status message, last-seen.
- **Chat** — direct messages (DM) and in-experience (world/room) chat with
**first-class safety**: server-side text filtering, report-message, and rate
limiting.
- **Groups** — named groups with roles (owner/admin/member) and a simple group
page.
- **Public profiles** — avatar, bio, favorite experiences, and a friends list
that respects privacy settings.
## 2. Why chat safety is the differentiator
The platform is explicitly meant to pull adults away from a youth-oriented
competitor. Chat is the highest-risk surface: it is real-time, user-generated,
and where abuse happens fastest. Safety is therefore treated as a **blocking
requirement**, not an add-on:
- Every message passes a **server-side text filter** before it is stored or
delivered. The client filter is cosmetic only; the server is authoritative.
- Every message can be **reported**; reports flow into a moderation queue with
actor + reason + audit trail (mirroring `content_moderation.md`).
- Sending is **rate limited** per user (Redis token bucket) to stop flooding and
harassment-by-volume.
- Report and block are always reachable from a message context in the UI.
## 3. Existing constraints (do not re-build)
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
`app/dependencies.py`. All social endpoints reuse this.
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
presence and rate limiting.
- **Models:** `User`, `World` (owner_id), `Asset` (owner_id, is_public). Avatars
and favorite-experience references point at existing rows.
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no social UI yet.
## 4. Key design decisions
| Concern | Decision |
|---|---|
| Message transport | REST for send/history; **WebSocket** gateway for real-time delivery + presence (matches `networking.md`'s "WebSocket as secondary transport" for chat) |
| Presence store | Redis (TTL heartbeat) + persisted `last_seen_at` on the user record |
| Text filtering | Pluggable `TextFilter` interface with a deterministic default (profanity list + normalization) so it runs without external services; third-party hook later |
| Rate limiting | Redis token-bucket dependency, reusable across all social endpoints |
| Reports | `message_reports` table + moderation endpoints (list/review/resolve) with audit fields |
| Friends vs. follow | Friends = mutual (request→accept). Follow = one-way (no approval). Block = hard deny that also hides content and prevents DMs |
| Privacy | Per-user settings JSON (`profile_visibility`, `friends_list_visibility`, `favorites_visibility`) enforced server-side on every read |
| Groups | `groups` + `group_members` (role: owner/admin/member) + group page aggregate endpoint |
| Real-time | One WebSocket endpoint multiplexing presence, DM, and world-chat messages via Redis pub/sub |
## 5. User stories
### US-1 Friends & following
As a player, I can send a friend request, accept/decline incoming requests,
unfriend, follow/unfollow other players, and block users so they can't interact
with me.
**Acceptance criteria**
- Friend requests are one-directional until accepted; accepted friendships are mutual.
- Follow is one-way and requires no approval.
- Block prevents DMs, hides the blocked user's profile/friends interactions, and
removes any existing friendship/follow between the two.
- Duplicate/self requests are rejected idempotently.
### US-2 Presence & status
As a player, I can see whether my friends are online, their custom status, and
when they were last seen.
**Acceptance criteria**
- Online/offline is derived from a server-side heartbeat (Redis TTL), not client claims.
- `last_seen_at` persists and is visible (subject to privacy settings).
- Custom status text is optional and length-limited.
### US-3 Public profiles
As a player, I can view a public profile page showing avatar, bio, favorite
experiences, and a friends list that respects the owner's privacy settings.
**Acceptance criteria**
- Avatar and bio are editable by the owner only.
- Favorite experiences reference existing `World` rows.
- A viewer sees only the sections the owner's privacy settings allow; blocked
users see a restricted view.
### US-4 Chat (DM + in-experience) with safety
As a player, I can send direct messages and chat inside a world/room. Messages
are filtered, rate limited, and reportable.
**Acceptance criteria**
- Server filters message text before storing/delivering; filtered content is either
rejected (blocking) or redacted (configurable per filter policy).
- Send rate is limited per user (token bucket); excess sends get `429`.
- Every message has a stable id, author, timestamp, and conversation/world scope.
- Any message can be reported with a reason; reports are stored and reviewable.
- Real-time delivery over WebSocket with REST fallback for history.
### US-5 Groups
As a player, I can create a group, join/leave groups, and (as owner/admin)
manage roles and view a simple group page.
**Acceptance criteria**
- Each group has exactly one owner and any number of admins/members.
- Role transitions are authorized (only owner can transfer ownership or promote/demote admins).
- Group page lists name, description, member list with roles, and member count.
### US-6 Godot client
As a player, I can access friends, presence, chat, profiles, and groups from the
game UI.
**Acceptance criteria**
- An API client handles JWT auth, HTTP, and WebSocket for all social calls.
- Dedicated screens/panels: friends+presence, DM & world chat (with report),
profile page, group page.
- Client-side filtering is cosmetic; server responses remain authoritative.
## 6. Task breakdown
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
are sequential for dependency chaining.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | Friends/follow/block models + Alembic migration | US-1 | — |
| 2 | Friends & follow API router | US-1 | 1 |
| 3 | Presence & status model + endpoints (Redis heartbeat) | US-2 | — |
| 4 | Profile model + migration + own-profile endpoints | US-3 | 1 |
| 5 | Public profile view + favorites + privacy enforcement | US-3 | 2, 4 |
| 6 | Chat data models + migration (conversations, messages, reports) | US-4 | — |
| 7 | Text filtering service + profanity config | US-4 | — |
| 8 | Redis rate-limiting dependency | US-4 | — |
| 9 | DM chat send/list endpoints (filter + rate limit) | US-4 | 6, 7, 8 |
| 10 | Report-message endpoint + moderation queue | US-4 | 6, 9 |
| 11 | In-experience (world/room) chat endpoints | US-4 | 9 |
| 12 | Real-time WebSocket gateway (presence + DM + world chat) | US-4 | 3, 9, 11 |
| 13 | Group + membership models + migration | US-5 | — |
| 14 | Groups API (CRUD, join/leave, roles, group page) | US-5 | 13 |
| 15 | Godot API client (auth + HTTP + WebSocket) | US-6 | 2 |
| 16 | Godot friends & presence UI | US-6 | 3, 15 |
| 17 | Godot chat UI (DM + world chat + report) | US-6 | 12, 15 |
| 18 | Godot profile page UI | US-6 | 5, 15 |
| 19 | Godot groups UI + group page | US-6 | 14, 15 |
## 7. Phasing
- **Phase 1 (data + core APIs):** tasks 1–5 (friends, presence, profiles).
- **Phase 2 (chat safety — the differentiator):** tasks 6–12 (chat + safety +
realtime). This is the critical path and highest priority.
- **Phase 3 (groups):** tasks 13–14.
- **Phase 4 (client):** tasks 15–19, each landing after its backend dependency.
## 8. Out of scope for this feature
- Age verification / maturity gating (covered by `content_moderation.md`).
- Voice chat, media/image messages, message editing/deletion history.
- Third-party profanity/toxicity vendors (the `TextFilter` interface leaves a
hook, but the default is deterministic and dependency-free).
- Mobile push notifications.