Skip to content

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 — Message model 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 MFD CommsCrewPage.tsx (list / refresh / send / reply-in-place / mark-read / delete — not a separate pages/Messages.tsx); GS accept / redact / block moderation ✅ tip-Shipped via #709 / LEG-1495 / LEG-263 on tip 46bce720. Remaining residuals: see Proven Findings. (re-verified 2026-08-21 vs Sectorwars2102 46bce720.)

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_id and sets reply_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.

Shippedthread_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:

  1. flagged = true is set on the message.
  2. flagged_reason records the player's category selection (harassment / spam / rule_break / other).
  3. The message routes to the admin moderation queue.
  4. An admin reviews; if action is taken, moderated_at and moderated_by are 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.pyMessageService.moderation_canon_action (reputation −50 redact / −100 block tip-PRESENT). Player flagging UI ✅ Shipped (LEG-412 / PR #706): MFD CommsCrewPage ⚑ FLAG + categories via messageAPI.flagMessagePOST /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 push transport — 📐 Design-only. NotificationService maps high / urgent to delivery surfaces including push (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 the push surface 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-client CommsCrewPage.tsx provides inbox list + reply-in-place only; git grep conversations under services/player-client/src shows 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.pyMessageService.moderation_canon_action; reputation −50/−100 tip-PRESENT). Distinct from legacy POST /admin/messages/{id}/moderate (delete|flag|unflag).
  • Admin-UI accept/redact/block callers — Soft-HOLD tip-absent Fibril #1655. MessageModeration.tsx still only delete|unflag via /admin/messages/{id}/moderate — do not invent Admin-UI canon-action buttons as tip-shipped.
  • 2-block → account_review status flip — 📐 Design-only / unmet (LEG-DEC-157). Tip logs an audit escalation marker only; no account_review player column invent.

Player-facing affordances

  • MFD Comms inbox (HAILS)CommsCrewPage.tsx: inbox list, refresh on auth hydration and live WS new_message events, send/reply-in-place composer (sendPlayerMessage with replyToId), mark-read on expand, PURGE soft-delete, unread badge via GameContext (origin/feat 46bce720).
  • Priority-driven toast + urgent modal — always-mounted PriorityHailConsumer.tsx renders WS-driven toasts for normal/high/urgent delivery and an action-interrupt modal for admin urgent; low is inbox-only (no toast/modal).
  • Player FLAG — expanded hail exposes ⚑ FLAG + canon category pickers → messageAPI.flagMessage / tip POST /messages/{id}/flag (LEG-412 / PR #706 merged on tip).
  • GS canon moderationaccept / 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/conversations is 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 per notification_service.py).
  • Soft-HOLD Admin-UI canon-action callers Fibril #1655MessageModeration.tsx still delete|unflag only; 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.pyPOST /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.