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); 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_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.
🚧 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:
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: 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) |
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.