Messaging¶
In-game player messaging system: direct messages between players, team broadcasts, threaded replies, priority levels, and admin moderation. Distinct from the realtime chat bus (see ../../SYSTEMS/realtime-bus.md) — messages are persistent records with a sender, recipient, subject, content, and read state, while the realtime bus carries transient chat events for live conversation.
Status: 🚧 Partial —
Messagemodel and core REST routes are shipped (services/gameserver/src/models/message.py,services/gameserver/src/api/routes/messages.py); priority-driven toast/modal delivery ✅ shipped (notification_service.py/message_service.py:191-203); player inbox UI ✅ Shipped at MFDCommsCrewPage.tsx(list / refresh / send / reply-in-place / mark-read / delete — not a separatepages/Messages.tsx); GSaccept/redact/blockmoderation ✅ tip-Shipped via #709 / LEG-1495 / LEG-263 on tip46bce720. Remaining residuals: see Proven Findings. (re-verified 2026-08-21 vs Sectorwars210246bce720.)
Purpose¶
Messaging exists for:
- Asynchronous coordination between players who aren't online at the same time (trade offers, alliance proposals, bounty tip-offs).
- Team-wide announcements that should reach every team member regardless of presence (raid scheduling, treasury policy, evacuation orders).
- System notifications that need a persistent record rather than a fleeting toast (registry events, governance policy enactment notices, bounty payouts, contract completion summaries).
The realtime chat bus handles in-the-moment conversation; messaging handles records the player should be able to revisit.
Message kinds¶
Three message_type values share the same underlying Message row:
| Type | Sender | Recipient | When used |
|---|---|---|---|
player |
Player | Single player UUID | Direct messages between two players. |
team |
Player or system | Team UUID (broadcast) | Sent to every member of a team. Each team member gets an individual unread state. |
system |
Server | Single player UUID | Automated notifications for registry events, governance policy results, bounty payouts, contract completions, etc. |
All three appear in the same player inbox; filters let players narrow by type.
Threading¶
Messages can reply to each other. Each Message carries a thread_id (conversation grouping) and an optional reply_to_id (direct reply pointer). Conventions:
- A new message starts a new thread (
thread_id = id,reply_to_id = null). - A reply inherits the parent's
thread_idand setsreply_to_id = parent.id. - Players can flat-list a thread by querying
WHERE thread_id = X ORDER BY sent_at.
Threads work for both 1:1 (player↔player) and team-broadcast (one team thread accumulating each member's replies).
Depth cap: threads support up to 50 messages. The 51st send returns thread_limit_exceeded; the player must archive or start a new thread. Older messages remain searchable via the thread_id index even if truncated from the active-thread view.
✅ Shipped — thread_id / reply_to_id columns (models/message.py:35-36), conversation retrieval, and the 50-message depth cap. MessageService sets THREAD_MESSAGE_CAP = 50 / THREAD_LIMIT_EXCEEDED = "thread_limit_exceeded" (message_service.py:39-40); on append to an existing thread it counts rows and raises HTTP 409 with that detail when existing_count >= THREAD_MESSAGE_CAP (:157-165), leaving new threads uncapped on first message. Client inbox + reply-in-place is ✅ Shipped on CommsCrewPage.tsx (see Source map). Dedicated thread browse / conversation-expansion UI (THREADS list/open via tip GET /messages/conversations) is 🚧 tip-pending (LEG-390 / PR #693 @ b40b9f9d, not tip ancestor) — tip CommsCrew still lacks that consumer; do not claim tip already ships THREADS, and do not treat a missing pages/Messages.tsx as an unbuilt inbox.
Priority levels¶
Each message carries one of four priority values that drive delivery and inbox surfacing:
| Priority | Behavior |
|---|---|
low |
Inbox only — no notification toast or push. |
normal (default) |
Inbox + in-game notification toast on arrival. |
high |
Inbox + toast + push notification (mobile / desktop) if the recipient is offline. |
urgent |
Inbox + toast + push + interrupts the recipient's current action with a modal (admin-only — players can't send urgent). |
System messages choose priority based on event severity: routine contract completion = normal; bounty hit on the player = high; admin announcement = urgent.
✅ Shipped — the priority field is stored and validated; NotificationService maps priority→surfaces (_DELIVERY_BY_PRIORITY / delivery_surfaces_for in services/gameserver/src/services/notification_service.py) and fans out a live WS new_message frame from every send path via MessageService._send_notification (message_service.py:191-203). Toast and modal interrupt are live (modal reserved for admin urgent). 📐 Design-only residual: offline push transport — the mapping may list push, but no push infra exists (logged only; covered by tests/unit/test_notification_service.py).
Subject and content¶
| Field | Constraint |
|---|---|
subject |
Optional, max 255 chars. Player-facing label; team broadcasts default to a [Team Name] prefix if blank. |
content |
Required, plain text. No markdown rendering at launch. UI sanitizes outbound HTML. |
Content length is not artificially capped at the model layer (Text column), but the player client enforces a 4,000-char soft limit per message before requiring a thread reply.
✅ Shipped — the send route (api/routes/messages.py MessageCreateRequest.content) enforces the 4,000-char max_length matching the soft cap specified here.
Rate limiting¶
The send path is protected by an anti-spam rate limit: a per-sender sliding window of 5 sends per 60 seconds. The POST /messages/send handler calls MessageService.check_send_rate_limit(sender_id) before any message is persisted; once a sender has logged 5 sends inside the trailing 60-second window, the 6th send is rejected with HTTP 429 and a retry hint (Too many messages — limit is 5 per 60s. Try again in Ns.). Timestamps that age out of the window are dropped, so capacity recovers continuously rather than resetting on a fixed boundary.
The window is held in process memory, making it per-worker: it resets on restart and is enforced independently by each worker. A multi-worker or multi-replica deployment must move it to a shared store (e.g. a per-sender Redis sorted set keyed on timestamp) for the cap to hold globally.
✅ Shipped — MessageService.check_send_rate_limit (services/gameserver/src/services/message_service.py) enforces the 5-per-60s sliding window and raises the 429; the send route (api/routes/messages.py) invokes it before persisting.
Inbox state machine¶
Each message has separate read/delete state per side:
| State | Meaning |
|---|---|
read_at IS NULL |
Recipient has not opened the message; counts toward unread badge. |
read_at set |
Recipient has opened the message. |
deleted_by_sender = true |
Sender's outbox hides the message; recipient still sees it. |
deleted_by_recipient = true |
Recipient's inbox hides the message; sender's sent folder still sees it. |
Soft deletes preserve the audit trail. Admin moderation can override either side's deletion.
Moderation¶
When a player flags a message as harassment, spam, or rule-breaking:
flagged = trueis set on the message.flagged_reasonrecords the player's category selection (harassment/spam/rule_break/other).- The message routes to the admin moderation queue.
- An admin reviews; if action is taken,
moderated_atandmoderated_byare set.
Moderation actions¶
| Action | Endpoint | Effect | Sender notified | Sender penalty |
|---|---|---|---|---|
accept |
POST /api/v1/admin/moderation/messages/{id}/accept |
Message stays visible; flag cleared | No | None |
redact |
POST /api/v1/admin/moderation/messages/{id}/redact |
Body replaced with [Moderated]; both parties see redaction |
Yes ("Your message was moderated for rule violation") | −50 personal_reputation |
block |
POST /api/v1/admin/moderation/messages/{id}/block |
Hidden from recipient; sender warned via system message | Yes ("Repeated violations may result in account restriction") | −100 personal_reputation |
Escalation (LEG-DEC-157 — unmet column): if a sender receives 2+ block actions within 30 real-time days, tip writes an audit-log escalation marker only (MessageService._record_block_and_maybe_escalate). Tip does not invent or flip an account_review player column / status — that account-review routing remains Design-only until LEG-DEC-157 lands (../../OPERATIONS/admin-ui.md).
Moderated messages remain in the database for the audit log even after content removal. The moderated_by field references users.id (admin staff), distinct from players.id.
✅ GS canon moderation tip-Shipped on tip 46bce720 (LEG-1495 / LEG-263 / PR #709 MERGED): POST /api/v1/admin/moderation/messages/{id}/{accept|redact|block} in admin_moderation_messages.py → MessageService.moderation_canon_action (reputation −50 redact / −100 block tip-PRESENT). Player flagging UI ✅ Shipped (LEG-412 / PR #706): MFD CommsCrewPage ⚑ FLAG + categories via messageAPI.flagMessage → POST /api/v1/messages/{id}/flag?reason= (10–255). Admin flagged inbox remains shipped. Residuals (do not invent): Admin-UI callers for accept/redact/block stay Soft-HOLD Fibril #1655 (MessageModeration.tsx still delete|unflag only via /admin/messages/{id}/moderate); 2-block→account_review column flip unmet (LEG-DEC-157).
REST routes¶
✅ Shipped — send, inbox, team feed, conversations, mark-read, soft-delete, player flag, and GS canon moderation (accept/redact/block) routes. Player routes: services/gameserver/src/api/routes/messages.py. Legacy admin moderate + list: services/gameserver/src/api/routes/admin_messages.py. Canon moderation: services/gameserver/src/api/routes/admin_moderation_messages.py (tip 46bce720 / #709).
Live route table (services/gameserver/src/api/routes/messages.py):
| Method | Path | Body / Params | Response | Notes |
|---|---|---|---|---|
| POST | /api/v1/messages/send |
{recipient_id?, team_id?, subject?, content (max 4000), priority, reply_to_id?} |
{message_id, sent_at} |
Exactly one of recipient_id or team_id. Priority ∈ low\|normal\|high\|urgent. Rate-limited per sender (5/60 s, in-process). |
| GET | /api/v1/messages/inbox |
?page&unread_only |
{messages: [Message], unread_count, total, page, limit, pages} |
50/page; ordered sent_at DESC. Only messages where caller is recipient and not soft-deleted. |
| GET | /api/v1/messages/team/{team_id} |
?page |
{messages: [Message], total, page, limit, pages} |
50/page; 403→ValueError if caller not a team member. |
| GET | /api/v1/messages/conversations |
?page |
{conversations: [Message], total, page, limit, pages} |
20/page. Each element is the latest Message per thread (not a summary object — the full message dict). |
| PUT | /api/v1/messages/{message_id}/read |
— | {success: true} |
Idempotent; 404 if not found or caller is not the recipient. |
| DELETE | /api/v1/messages/{message_id} |
— | {success: true} |
Soft delete; sets deleted_by_sender or deleted_by_recipient per caller role. 404 if not found or not visible to caller. |
| POST | /api/v1/messages/{message_id}/flag |
?reason (query param, 10–255 chars) |
{success: true} |
404 if not found. Alerts all active admin users via WebSocket. |
| GET | /api/v1/admin/messages/all |
?page&flagged |
{messages: [Message], total, page, limit, pages} |
100/page; ordered sent_at DESC. Admin only. flagged=true filters to flagged-only. |
| POST | /api/v1/admin/moderation/messages/{message_id}/accept |
optional {reason?} |
canon moderation result | ✅ tip-Shipped #709 / admin_moderation_messages.py. Clears flag; no reputation penalty. Distinct from /admin/messages/{id}/moderate. |
| POST | /api/v1/admin/moderation/messages/{message_id}/redact |
optional {reason?} |
canon moderation result | ✅ tip-Shipped #709. Body → [Moderated]; −50 personal_reputation. |
| POST | /api/v1/admin/moderation/messages/{message_id}/block |
optional {reason?} |
canon moderation result | ✅ tip-Shipped #709. Hidden from recipient reads; −100 personal_reputation; 2+/30d → audit escalation marker only (LEG-DEC-157 — no account_review invent). |
Message object shape (from Message.to_dict(), services/gameserver/src/models/message.py:67):
{
"id": "uuid",
"sender_id": "uuid",
"recipient_id": "uuid|null",
"team_id": "uuid|null",
"subject": "string|null",
"content": "string",
"message_type": "player|team|system",
"priority": "low|normal|high|urgent",
"thread_id": "uuid|null",
"reply_to_id": "uuid|null",
"sent_at": "ISO 8601|null",
"read_at": "ISO 8601|null",
"flagged": false,
"is_read": false,
"sender_name": "string"
}
sender_name (Player.nickname) is present when the sender relationship is eager-loaded — inbox, team, and conversations routes all use joinedload(Message.sender); the admin route does not explicitly eager-load (may appear via lazy load depending on session state). content is always included (no include_content=False path in any of these routes).
Composite indexes¶
The Message schema carries three composite indexes for the common query patterns:
(recipient_id, read_at)— "show me my unread messages" — primary inbox query.(team_id, sent_at)— "team feed paginated by recency" — team channel view.(thread_id, sent_at)— "show me this conversation in order" — thread expansion.
See ../../DATA_MODELS/player.md#message for the full column list.
Proven Findings¶
Launch-target / Soft-HOLD residuals on origin/feat 46bce720 (evidence-backed; do not read unmet items as shipped):
- Offline
pushtransport — 📐 Design-only.NotificationServicemapshigh/urgentto delivery surfaces includingpush(notification_service.py:59-60,_DELIVERY_BY_PRIORITY), but the module docstring states push infrastructure does not exist anywhere in the stack — no service worker, Web Push, or push-token store (notification_service.py:29-34). The service logs that a message earned thepushsurface but does not dispatch push (notification_service.py:122-127). - Dedicated thread-view / conversation-expansion UI — 🚧 Partial. Server ships
GET /messages/conversations(api/routes/messages.py:193-214). Player-clientCommsCrewPage.tsxprovides inbox list + reply-in-place only;git grep conversationsunderservices/player-client/srcshows no THREADS browse consumer beyond per-row expand (LEG-390 / PR #693 not tip-ancestor). - GS canon moderation
accept/redact/block— ✅ tip-Shipped via #709 / LEG-1495 / LEG-263 (admin_moderation_messages.py→MessageService.moderation_canon_action; reputation −50/−100 tip-PRESENT). Distinct from legacyPOST /admin/messages/{id}/moderate(delete|flag|unflag). - Admin-UI accept/redact/block callers — Soft-HOLD tip-absent Fibril
#1655.MessageModeration.tsxstill onlydelete|unflagvia/admin/messages/{id}/moderate— do not invent Admin-UI canon-action buttons as tip-shipped. - 2-block →
account_reviewstatus flip — 📐 Design-only / unmet (LEG-DEC-157). Tip logs an audit escalation marker only; noaccount_reviewplayer column invent.
Player-facing affordances¶
- ✅ MFD Comms inbox (HAILS) —
CommsCrewPage.tsx: inbox list, refresh on auth hydration and live WSnew_messageevents, send/reply-in-place composer (sendPlayerMessagewithreplyToId), mark-read on expand, PURGE soft-delete, unread badge viaGameContext(origin/feat46bce720). - ✅ Priority-driven toast + urgent modal — always-mounted
PriorityHailConsumer.tsxrenders WS-driven toasts fornormal/high/urgentdelivery and an action-interrupt modal for adminurgent;lowis inbox-only (no toast/modal). - ✅ Player FLAG — expanded hail exposes ⚑ FLAG + canon category pickers →
messageAPI.flagMessage/ tipPOST /messages/{id}/flag(LEG-412 / PR #706 merged on tip). - ✅ GS canon moderation —
accept/redact/block+ reputation penalties tip-PRESENT via #709 / LEG-1495 / LEG-263 (POST /api/v1/admin/moderation/messages/{id}/{action}). - 🚧 Dedicated thread browse (THREADS) —
GET /messages/conversationsis shipped server-side; tip has no separate conversations consumer beyond reply-in-place on individual inbox rows (LEG-390 / PR #693 not tip ancestor). - 📐 Offline push transport — priority mapping may list
push, but no push infra exists on tip (logged only pernotification_service.py). - Soft-HOLD Admin-UI canon-action callers Fibril
#1655—MessageModeration.tsxstilldelete|unflagonly; do not claim Admin-UI accept/redact/block buttons tip-shipped. - 📐 2-block →
account_review— unmet (LEG-DEC-157); tip audit marker only.
Source map¶
| Concern | Path (target) |
|---|---|
Message model |
services/gameserver/src/models/message.py |
| Messaging service (incl. send rate limit) | services/gameserver/src/services/message_service.py |
| REST routes | services/gameserver/src/api/routes/messages.py |
Canon moderation routes (✅ tip-Shipped 46bce720 / #709) |
services/gameserver/src/api/routes/admin_moderation_messages.py — POST /admin/moderation/messages/{id}/{accept\|redact\|block} → MessageService.moderation_canon_action |
Legacy admin moderate (delete|flag|unflag) |
services/gameserver/src/api/routes/admin_messages.py |
| Notification fan-out (priority-driven) | services/gameserver/src/services/notification_service.py integrating with the realtime bus |
| Player-client inbox UI (✅ Shipped) | services/player-client/src/components/mfd/pages/CommsCrewPage.tsx — MFD-B COMM HAILS inbox + composer (GameContext inboxMessages / refreshInbox / send / mark-read / delete / reply-in-place); always-mounted priority hail refresh via services/player-client/src/components/comms/PriorityHailConsumer.tsx. This is the live inbox surface — a standalone pages/Messages.tsx is not required. |
| Player FLAG UI (✅ Shipped) | Same CommsCrewPage.tsx — FLAG + category pickers → messageAPI.flagMessage / tip POST /messages/{id}/flag (LEG-412 / PR #706). |
| Admin-UI queue (Soft-HOLD for canon actions) | services/admin-ui/src/components/pages/MessageModeration.tsx — flagged inbox + delete|unflag only; accept/redact/block UI callers tip-absent. |
Cross-links¶
../../SYSTEMS/realtime-bus.md— transient chat events (the conversational counterpart to this persistent system).../../DATA_MODELS/player.md#message— full column schema../factions-and-teams.md— team membership drives team-broadcast eligibility.../../OPERATIONS/admin-ui.md— admin moderation queue.