Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ace1d8400 | |||
| a9947a0a80 | |||
| 99762887be | |||
| 1061dc0742 |
@@ -0,0 +1 @@
|
||||
"""Application services."""
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Pluggable server-side text filtering service.
|
||||
|
||||
Provides a ``TextFilter`` protocol with a deterministic default
|
||||
implementation (profanity/term list with normalization) and a hook
|
||||
for third-party providers.
|
||||
|
||||
Usage::
|
||||
|
||||
from app.services.text_filter import TextFilter, FilterResult, \
|
||||
ProfanityTextFilter, get_text_filter
|
||||
|
||||
# Default factory reads config from the path below
|
||||
filt = get_text_filter()
|
||||
result = filt.check("Hello wor1d!")
|
||||
# result.action == FilterAction.ALLOW
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums / Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FilterAction(str, Enum):
|
||||
"""Action the filter wants the caller to take."""
|
||||
|
||||
ALLOW = "allow"
|
||||
REDACT = "redact"
|
||||
REJECT = "reject"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilterResult:
|
||||
"""Immutable result of a single ``TextFilter.check()`` call.
|
||||
|
||||
Attributes:
|
||||
action: What to do with the message.
|
||||
filtered_text: The text after redaction (may equal *text* if
|
||||
action is ``ALLOW``).
|
||||
matched_terms: Sorted list of terms that triggered the filter.
|
||||
"""
|
||||
|
||||
action: FilterAction
|
||||
filtered_text: str
|
||||
matched_terms: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_allowed(self) -> bool:
|
||||
return self.action == FilterAction.ALLOW
|
||||
|
||||
@property
|
||||
def is_rejected(self) -> bool:
|
||||
return self.action == FilterAction.REJECT
|
||||
|
||||
@property
|
||||
def is_redacted(self) -> bool:
|
||||
return self.action == FilterAction.REDACT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol / abstract base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TextFilter(ABC):
|
||||
"""Protocol for server-side text filters.
|
||||
|
||||
Every concrete filter must implement ``check()``. The default
|
||||
implementation (``ProfanityTextFilter``) is deterministic and
|
||||
requires no external service. A third-party provider can be
|
||||
plugged in by subclassing ``TextFilter`` and overriding ``check``.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def check(self, text: str) -> FilterResult:
|
||||
"""Analyse *text* and return a ``FilterResult``.
|
||||
|
||||
Args:
|
||||
text: Raw message text to filter.
|
||||
|
||||
Returns:
|
||||
A ``FilterResult`` with the decision and any redacted text.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Normalisation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Common leetspeak substitutions (char -> set of possible originals)
|
||||
_LEETSPEAK: dict[str, str] = {
|
||||
"0": "o",
|
||||
"1": "i",
|
||||
"3": "e",
|
||||
"4": "a",
|
||||
"5": "s",
|
||||
"7": "t",
|
||||
"8": "b",
|
||||
"9": "g",
|
||||
"@": "a",
|
||||
}
|
||||
|
||||
# Normalise separators that are often used to obfuscate words
|
||||
_SEPARATORS = re.compile(r"[\s_\-\.]+")
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Case-fold, strip diacritics, and replace leetspeak / separators.
|
||||
|
||||
This produces a canonical form for matching against the term list.
|
||||
"""
|
||||
# Case-fold
|
||||
text = text.casefold()
|
||||
# Strip diacritics (e.g. é -> e)
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
# Replace leetspeak
|
||||
normalized = ""
|
||||
for ch in text:
|
||||
normalized += _LEETSPEAK.get(ch, ch)
|
||||
# Collapse separators
|
||||
normalized = _SEPARATORS.sub("", normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Default config path (relative to project root)
|
||||
_DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "text_filter_config.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FilterPolicy:
|
||||
"""Filtering policy loaded from config."""
|
||||
|
||||
mode: str # "redact" or "reject"
|
||||
redaction_char: str = "*"
|
||||
# Minimum term length to consider (avoids matching single chars)
|
||||
min_term_length: int = 3
|
||||
# If True, match terms case-insensitively after normalisation
|
||||
case_insensitive: bool = True
|
||||
# If True, match whole words only (after normalisation)
|
||||
whole_word: bool = True
|
||||
|
||||
|
||||
class ProfanityTextFilter(TextFilter):
|
||||
"""Deterministic text filter backed by a JSON term list.
|
||||
|
||||
The config file is a JSON object with:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"mode": "redact",
|
||||
"redaction_char": "*",
|
||||
"min_term_length": 3,
|
||||
"case_insensitive": true,
|
||||
"whole_word": true,
|
||||
"terms": ["badword", "another", ...]
|
||||
}
|
||||
|
||||
Terms are matched after normalisation (case-fold, leetspeak
|
||||
substitution, separator removal, diacritic stripping).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str | Path | None = None,
|
||||
terms: list[str] | None = None,
|
||||
policy: _FilterPolicy | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
config_path: Path to a JSON config file. Ignored if *terms*
|
||||
is provided.
|
||||
terms: Inline list of terms (overrides config file).
|
||||
policy: Filter policy. Defaults to a redact policy.
|
||||
"""
|
||||
if terms is not None:
|
||||
self._terms: list[str] = [t.lower() for t in terms]
|
||||
elif config_path is not None:
|
||||
self._terms = self._load_terms(Path(config_path))
|
||||
else:
|
||||
# Try default path
|
||||
if _DEFAULT_CONFIG_PATH.exists():
|
||||
self._terms = self._load_terms(_DEFAULT_CONFIG_PATH)
|
||||
else:
|
||||
self._terms = []
|
||||
|
||||
self._policy = policy or _FilterPolicy(mode="redact")
|
||||
# Pre-compile patterns for each term
|
||||
self._patterns: list[tuple[re.Pattern[str], str]] = []
|
||||
for term in self._terms:
|
||||
if len(term) < self._policy.min_term_length:
|
||||
continue
|
||||
if self._policy.whole_word:
|
||||
pattern = re.compile(
|
||||
r"(?<!\w)" + re.escape(term) + r"(?!\w)",
|
||||
re.IGNORECASE if self._policy.case_insensitive else 0,
|
||||
)
|
||||
else:
|
||||
pattern = re.compile(
|
||||
re.escape(term),
|
||||
re.IGNORECASE if self._policy.case_insensitive else 0,
|
||||
)
|
||||
self._patterns.append((pattern, term))
|
||||
|
||||
# -- config loading --------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _load_terms(config_path: Path) -> list[str]:
|
||||
"""Load terms from a JSON config file."""
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
data: dict[str, Any] = json.load(f)
|
||||
|
||||
terms: list[str] = []
|
||||
for raw in data.get("terms", []):
|
||||
term = str(raw).strip()
|
||||
if term:
|
||||
terms.append(term.casefold())
|
||||
return terms
|
||||
|
||||
# -- public API ------------------------------------------------------
|
||||
|
||||
def check(self, text: str) -> FilterResult:
|
||||
"""Check *text* against the loaded term list.
|
||||
|
||||
Returns a ``FilterResult`` with the appropriate action,
|
||||
filtered text, and matched terms.
|
||||
"""
|
||||
if not text:
|
||||
return FilterResult(
|
||||
action=FilterAction.ALLOW,
|
||||
filtered_text=text,
|
||||
matched_terms=[],
|
||||
)
|
||||
|
||||
normalized = _normalize(text)
|
||||
matched_terms: list[str] = []
|
||||
|
||||
# Collect all matches
|
||||
for pattern, term in self._patterns:
|
||||
if pattern.search(normalized):
|
||||
if term not in matched_terms:
|
||||
matched_terms.append(term)
|
||||
|
||||
if not matched_terms:
|
||||
return FilterResult(
|
||||
action=FilterAction.ALLOW,
|
||||
filtered_text=text,
|
||||
matched_terms=[],
|
||||
)
|
||||
|
||||
matched_terms.sort()
|
||||
|
||||
# Build filtered text by replacing matched terms
|
||||
filtered = text
|
||||
for term in matched_terms:
|
||||
norm_term = _normalize(term)
|
||||
# Find all occurrences in the normalised text and replace in original
|
||||
# We need to map positions from normalised text back to original
|
||||
filtered = self._replace_terms(filtered, term, norm_term)
|
||||
|
||||
action = FilterAction.REJECT if self._policy.mode == "reject" else FilterAction.REDACT
|
||||
|
||||
return FilterResult(
|
||||
action=action,
|
||||
filtered_text=filtered,
|
||||
matched_terms=matched_terms,
|
||||
)
|
||||
|
||||
def _replace_terms(
|
||||
self, text: str, term: str, norm_term: str
|
||||
) -> str:
|
||||
"""Replace occurrences of *term* in *text* with redaction chars.
|
||||
|
||||
Uses normalised text for matching but preserves original casing
|
||||
in the output.
|
||||
"""
|
||||
norm_text = _normalize(text)
|
||||
norm_term_len = len(norm_term)
|
||||
result_chars = list(text)
|
||||
|
||||
start = 0
|
||||
while start < len(norm_text):
|
||||
idx = norm_text.find(norm_term, start)
|
||||
if idx == -1:
|
||||
break
|
||||
# Replace characters in original text
|
||||
for i in range(idx, min(idx + norm_term_len, len(result_chars))):
|
||||
result_chars[i] = self._policy.redaction_char
|
||||
start = idx + norm_term_len
|
||||
|
||||
return "".join(result_chars)
|
||||
|
||||
@property
|
||||
def terms(self) -> list[str]:
|
||||
"""Read-only access to the loaded term list."""
|
||||
return list(self._terms)
|
||||
|
||||
@property
|
||||
def policy(self) -> _FilterPolicy:
|
||||
"""Read-only access to the filter policy."""
|
||||
return self._policy
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_text_filter(
|
||||
config_path: str | Path | None = None,
|
||||
terms: list[str] | None = None,
|
||||
policy: _FilterPolicy | None = None,
|
||||
) -> TextFilter:
|
||||
"""Return a ``TextFilter`` instance.
|
||||
|
||||
By default this creates a ``ProfanityTextFilter``. To use a
|
||||
third-party provider, subclass ``TextFilter`` and pass an
|
||||
instance here instead.
|
||||
"""
|
||||
return ProfanityTextFilter(
|
||||
config_path=config_path,
|
||||
terms=terms,
|
||||
policy=policy,
|
||||
)
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user