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); threading UI, the full moderation action set, and priority-driven notification delivery are launch-target.

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.

🚧 Partial — thread_id / reply_to_id columns and conversation retrieval are shipped; the 50-message depth cap and thread_limit_exceeded enforcement are launch-target.

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.

🚧 Partial — the priority field is stored and validated; priority-driven delivery (toast / push / modal interrupt) is launch-target.

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: if a sender receives 2+ block actions within 30 real-time days, their account flips to account_review status and routes to the broader admin investigation queue (../../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.

🚧 Partial — player flagging and an admin moderation queue are shipped; the accept / redact / block action set, reputation penalties, and the 2-block escalation are launch-target.

REST routes

✅ Shipped — send, inbox, team feed, conversations, mark-read, and soft-delete routes. Routes are at services/gameserver/src/api/routes/messages.py (player) and services/gameserver/src/api/routes/admin_messages.py (admin).

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.

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.

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
Moderation queue services/gameserver/src/services/moderation_service.py (target)
Notification fan-out (priority-driven) services/gameserver/src/services/notification_service.py integrating with the realtime bus
Player-client inbox UI services/player-client/src/pages/Messages.tsx (target)