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; player inbox UI ✅ Shipped at MFDCommsCrewPage.tsx(HAILS + THREADS tab browse viamessageAPI.getConversations); GSaccept/redact/blockmoderation ✅ tip-Shipped via #709 on tip57243131e. Remaining residuals: see Proven Findings. (re-verified 2026-09-02 vs Sectorwars2102 feat57243131e. Tip delta from prior70f1d3cc4stamp: densify merge train through #1367 — no messaging-kernel change.)
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 THREADS browse via GET /messages/conversations is ✅ tip-PRESENT on 2d64087f4 — CommsCrewPage.tsx HAILS/THREADS mode switch (:72, :487-489), messageAPI.getConversations load (:308), thread list UI + Vitest hails nameplate test.
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). |
The supported player-message request paths are:
- Direct request: set
recipient_idand leaveteam_idunset. - Team request: set
team_idand leaverecipient_idunset; the message is delivered to every team member.
Players may choose low, normal, or high on either path. Only an authorized administrator may choose urgent on a direct or team request; the urgent rule is an authorization boundary, not a restriction on the other three priorities. Authorization is checked before target-shape or team-membership checks: if a caller without administrator authorization attempts urgent on either path, the server returns HTTP 403 with the stable message Not authorized to send urgent messages. Authorized administrators may use urgent on either path.
Offline push notifications carry delivery metadata only. A push payload must not include the message subject or content; the recipient opens the authenticated inbox to retrieve the message body. 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 2d64087f4 (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. Admin-UI canon-action callers ✅ tip-PRESENT on tip 7012c53e5 (LEG-1579/2690): MessageModeration.tsx ships Accept / Redact / Block buttons (~384–428) calling POST /api/v1/admin/moderation/messages/{id}/{accept|redact|block}, bulk-moderate via POST /api/v1/admin/messages/bulk-moderate (~502), and block-count badges — Vitest MessageModeration.test.tsx. Legacy delete|unflag via /admin/messages/{id}/moderate may still coexist. Residual (do not invent): 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 2d64087f4 / #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 2d64087f4 (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 — ✅ tip-PRESENT. Server ships
GET /messages/conversations(api/routes/messages.py:193-214). Player-clientCommsCrewPage.tsxTHREADS tab loads conversations (messageAPI.getConversations:308) with dedicated browse UI (:487-626) on tip2d64087f4. - 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 — ✅ tip-PRESENT on tip
7012c53e5(MessageModeration.tsxAccept/Redact/Block + bulk-moderate; see § Moderation). - 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/feat2d64087f4). - ✅ 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) —
CommsCrewPage.tsxHAILS/THREADS mode +messageAPI.getConversationsconsumer tip-PRESENT on2d64087f4(:308,:487-626); Vitest nameplate pins inCommsCrewPage.test.tsx. - 📐 Offline push transport — priority mapping may list
push, but no push infra exists on tip (logged only pernotification_service.py). - ✅ Admin-UI canon-action callers —
MessageModeration.tsxAccept/Redact/Block + bulk-moderate tip-PRESENT on7012c53e5. - 📐 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 2d64087f4 / #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 moderation queue (✅ tip-PRESENT canon actions) | services/admin-ui/src/components/pages/MessageModeration.tsx — flagged inbox + Accept/Redact/Block canon buttons (~384–428), bulk-moderate (~502), block-count badges; Vitest MessageModeration.test.tsx |
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.