using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Models.Notification; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Messages.V2; /// /// api.rec.net/api/messages/v2/* — direct-message inbox API. Backed /// by . Sends fan out a SignalR push /// (MessageReceived) so the recipient's watch refreshes /// immediately. /// [ApiController] [Route("api/[controller]/v2")] [Authorize] public class MessagesController( DorkNetDbContext db, NotificationService notifications, PrivateInstanceService privateInstances, OnlinePresenceService onlinePresence, ILogger logger) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); /// List the caller's inbox, newest-first. Response is a /// flat array of ; the watch groups by /// sender locally. v1/v3 paths share the same handler — the watch /// uses whichever URL its RecNet.Messages static helper /// built from GameConfig.MessageServiceVersion. [HttpGet("get")] [HttpGet("/api/messages/v1/get")] [HttpGet("/api/messages/v3/get")] [HttpGet("/api/messages/v1/onlineStatus")] public async Task>> GetMessages([FromQuery] int take = 100) { take = Math.Clamp(take, 1, 200); var rows = await db.Messages .Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me) // Defensive filter for the empty-body ghost rows that // accumulated before SendFormMessage rejected them. The // watch's MessagesScreenItem renders TextMessage (type 30) // as "Message : {body}", so an empty body becomes a // useless "Message :" entry the user has to scroll past. // Control-channel types (10 RequestGameInvite, 20 // FriendStatusOnline, etc.) have their own labels and // legitimately carry no body, so we keep them. .Where(m => m.Type != 30 || (m.Body != null && m.Body != "")) .OrderByDescending(m => m.SentAt) .Take(take) .ToListAsync(); return Ok(await ToDtosAsync(rows)); } /// GET api/messages/v1/unread — list of unread /// messages addressed to the caller. The watch uses the count for /// the inbox badge and the entries themselves for the toast /// preview. [HttpGet("/api/messages/v1/unread")] public async Task>> Unread() { var rows = await db.Messages .Where(m => m.RecipientPlayerId == Me && m.ReadAt == null) // Same ghost-row filter as GetMessages above — keep the // unread badge count honest by hiding empty TextMessages. .Where(m => m.Type != 30 || (m.Body != null && m.Body != "")) .OrderByDescending(m => m.SentAt) .Take(100) .ToListAsync(); return Ok(await ToDtosAsync(rows)); } public sealed class DeleteRequest { public long? Id { get; set; } public long? MessageId { get; set; } public List? MessageIds { get; set; } } /// POST api/messages/v3/delete — hard-delete one or /// more messages addressed to the caller. The 2020 watch's inbox /// "delete" action posts JSON {"MessageIds":[8]} (plural /// array, verified from the live trace where the body was /// {"MessageIds":[8]} against /api/messages/v3/delete); /// some older / SPA call sites still post id or /// messageId as a form/query. Accept all three shapes and /// scope every delete to RecipientPlayerId == Me so the /// sender can't unsend. [HttpPost("/api/messages/v3/delete")] [HttpPost("/api/messages/v1/delete")] [Consumes("application/json")] public async Task DeleteJson([FromBody] DeleteRequest body) { var ids = new List(); if (body.Id is long a && a > 0) ids.Add(a); if (body.MessageId is long b && b > 0) ids.Add(b); if (body.MessageIds is { Count: > 0 }) ids.AddRange(body.MessageIds.Where(v => v > 0)); return await DeleteByIds(ids); } [HttpPost("/api/messages/v3/delete")] [HttpPost("/api/messages/v1/delete")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task DeleteForm( [FromForm(Name = "id")] long? formId, [FromForm(Name = "messageId")] long? formMessageId, [FromForm(Name = "messageIds")] string? messageIdsCsv) { var ids = new List(); if (formId is long a && a > 0) ids.Add(a); if (formMessageId is long b && b > 0) ids.Add(b); if (long.TryParse(Request.Query["id"], out var qi) && qi > 0) ids.Add(qi); if (!string.IsNullOrWhiteSpace(messageIdsCsv)) { foreach (var s in messageIdsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries)) if (long.TryParse(s.Trim(), out var v) && v > 0) ids.Add(v); } return await DeleteByIds(ids); } private async Task DeleteByIds(List ids) { ids = ids.Distinct().ToList(); if (ids.Count == 0) return BadRequest(new { error = "missing_id" }); var deleted = await db.Messages .Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me) .ExecuteDeleteAsync(); return Ok(new { success = true, error = "", deleted }); } /// POST api/messages/v3/markRead — bulk /// mark-as-read for every unread message addressed to the caller. /// Optional ids CSV scopes to specific message ids. [HttpPost("/api/messages/v3/markRead")] [HttpPost("/api/messages/v1/markRead")] public async Task MarkReadBulk([FromForm(Name = "ids")] string? ids) { IQueryable q = db.Messages .Where(m => m.RecipientPlayerId == Me && m.ReadAt == null); if (!string.IsNullOrWhiteSpace(ids)) { var idSet = ids .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(s => long.TryParse(s, out var v) ? v : 0L) .Where(v => v > 0) .ToHashSet(); if (idSet.Count > 0) q = q.Where(m => idSet.Contains(m.Id)); } var now = DateTime.UtcNow; await q.ExecuteUpdateAsync(s => s.SetProperty(m => m.ReadAt, now)); return Ok(new { success = true, error = "" }); } public sealed record SendRequest(long RecipientId, string Body, long? RoomId = null); /// Send a direct message — JSON wire shape used by some /// SPA/admin callers. The 2020 watch posts to the same path with /// form-urlencoded fields (); the /// [Consumes] attribute disambiguates which action MVC /// picks. Empty bodies are rejected so the inbox doesn't get /// padded with whitespace pings; messages over 2000 chars are /// truncated to fit the column. [HttpPost("send")] [HttpPost("/api/messages/v1/send")] [Consumes("application/json")] public async Task> SendMessage([FromBody] SendRequest req) { if (req.RecipientId <= 0 || req.RecipientId == Me) return BadRequest(new { error = "invalid_recipient" }); var trimmed = (req.Body ?? string.Empty).Trim(); if (trimmed.Length == 0) return BadRequest(new { error = "empty_body" }); if (trimmed.Length > 2000) trimmed = trimmed[..2000]; var entry = new MessageEntity { SenderPlayerId = Me, RecipientPlayerId = req.RecipientId, Body = trimmed, Type = 30, // TextMessage RoomId = req.RoomId, }; db.Messages.Add(entry); await db.SaveChangesAsync(); await BroadcastMessageAsync(entry); return Ok(ToDto(entry)); } /// Form-urlencoded variant — what the 2020 watch actually /// posts when the user taps "Send Join Request" or any other /// RecNet.Messages.SendMessage(player, type, data) caller. /// Wire shape (verified against client log contentType= /// application/x-www-form-urlencoded, body ToPlayerId=…& /// Type=10&Data=): the Type integer is a /// RecNet.Message.MessageType enum value /// (Cpp2IL_CS/.../RecNet/Message.cs) — 10 = /// RequestGameInvite, 30 = TextMessage, etc. Empty Data is /// allowed for control-channel types (join request, declines) /// where the message itself is the signal. [HttpPost("send")] [HttpPost("/api/messages/v1/send")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task> SendFormMessage( [FromForm(Name = "ToPlayerId")] long toPlayerId, [FromForm(Name = "Type")] int type, [FromForm(Name = "Data")] string? data, [FromForm(Name = "RoomId")] long? roomId) { if (toPlayerId <= 0 || toPlayerId == Me) return BadRequest(new { error = "invalid_recipient" }); var trimmed = (data ?? string.Empty); if (trimmed.Length > 2000) trimmed = trimmed[..2000]; // For TextMessage (type 30) the body IS the message — empty // is meaningless and renders as a ghost "Message :" row in // the watch's inbox (MessagesScreenItem format string at // Cpp2IL_ISIL/.../MessagesScreenItem.txt:2145 is // "Message : {0}"). Control-channel types (RequestGameInvite, // declines, FriendStatusOnline, etc.) intentionally carry // empty Data — they get rendered by their own label string // in the same screen item. if (type == 30 && trimmed.Trim().Length == 0) return BadRequest(new { error = "empty_text_message" }); var entry = new MessageEntity { SenderPlayerId = Me, RecipientPlayerId = toPlayerId, Body = trimmed, Type = type, RoomId = roomId, }; db.Messages.Add(entry); await db.SaveChangesAsync(); logger.LogInformation( "[messages] send from={From} to={To} type={Type} dataLen={Len}", Me, toPlayerId, type, trimmed.Length); await BroadcastMessageAsync(entry); return Ok(ToDto(entry)); } /// POST invite — handles the watch's /// RecNet.Messages.SendGameInvite(playerId, roomInstanceId) /// (verified at Cpp2IL_ISIL/.../Messages.txt:613-773): form /// fields playerId (recipient) + roomInstanceId /// (the inviter's private match id). Two side-effects: /// 1. Add the recipient to the invite list for that instance so /// POST /goto/instance/{id} accepts them. /// 2. Drop a Type-6 (GameInviteV2) message in their inbox carrying the /// RoomInstanceId so the message UI's "Join" button knows /// where to send them. /// Registered at multiple paths because the exact URL the watch /// builds for "invite" depends on the messages-namespace base — /// rather than guess wrong and silently fall through to the /// wildcard, we cover all plausible mounts and log the one that /// actually fires. [HttpPost("/invite")] [HttpPost("/api/invite")] [HttpPost("/api/messages/v1/invite")] [HttpPost("/api/messages/v2/invite")] [HttpPost("/api/messages/v3/invite")] public async Task SendInvite( [FromForm(Name = "playerId")] long playerId, [FromForm(Name = "roomInstanceId")] long roomInstanceId) { logger.LogInformation( "[invite] from={From} → to={To} instance={Instance} (path={Path})", Me, playerId, roomInstanceId, Request.Path); if (playerId <= 0 || playerId == Me) return BadRequest(new { error = "invalid_recipient" }); // Only the OWNER of the private instance can invite into it. // Otherwise an attacker who somehow learned a roomInstanceId // could invite themselves (or anyone) freely. var inst = await privateInstances.GetAsync(roomInstanceId); if (inst is null) return BadRequest(new { error = "instance_not_found", roomInstanceId }); if (inst.OwnerPlayerId != Me) return Forbid(); // Persist a real RecNet.Message.MessageType.GameInviteV2 (=6) // so the watch's GameInviteController calls // Matchmaking.AcceptRoomInvite(inviteId, roomId), which posts // goto/invite/{inviteId}. Type 0 follows the sender's current // presence instead and can land in the wrong instance. // // Two saves: the first allocates the message Id (which becomes // the `inviteId` the watch sends back in /goto/invite/{N}); the // second backfills the JSON Body once we know it. We can't // pre-allocate the Id because EF Core's identity columns only // populate on SaveChanges. var roomName = await db.Rooms .Where(r => r.Id == inst.RoomId) .Select(r => r.Name) .FirstOrDefaultAsync() ?? "Private match"; var entry = new MessageEntity { SenderPlayerId = Me, RecipientPlayerId = playerId, // Placeholder so the row passes the NOT NULL constraint // until we overwrite it post-save with the real JSON. Body = string.Empty, Type = 6, RoomId = inst.RoomId, }; db.Messages.Add(entry); await db.SaveChangesAsync(); // InviteAsync AFTER the message save so we can pin the freshly- // allocated Message.Id onto the invitee row's // LatestInviteMessageId. This is what /goto/invite/{N} falls // back to when the watch's accept flow has already raced // through DELETE /api/messages/v3/delete — the message row is // gone but the (instance, player) → messageId mapping survives. await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id); // Body shape MUST be the JSON the watch's Message.Deserialize // builds RoomInviteDetails from (verified at // Cpp2IL_ISIL/.../RecNet/Message.txt:786-805 — for Type 6/7 // the parser hits `Json.Deserialize` and reads "inviteId" + // "name"; for other types it `Int32.Parse`s the body). // // inviteId → the message Id; watch echoes it back in // /goto/invite/{inviteId}, server looks the // row up to resolve roomInstanceId. // name → display string in the invite toast / row. // roomInstanceId → extra field the watch ignores; server uses // it (via TryParseRoomInstanceId's JSON // fallback) so /goto/invite/{N} doesn't have // to re-query the private-instance service. // // The roomInstanceId MUST be a JSON STRING, not a number. LitJson // chokes on the dict-cast when a value is a 64-bit integer larger // than int32 — confirmed via SaveDebugMod trace: the watch logs // `data={"inviteId":31,"name":"Dorm_1811750","roomInstanceId":6546545006375796736}` // `RoomInviteDetails=` // `System.NullReferenceException at RecNet.Util.GetKey from // RecNet.Message.Deserialize` // Json.Deserialize returns a null cast-result on the long value, // GetKey("inviteId") then NREs and the whole parse aborts before // RoomInviteDetails is built. Stringifying the value sidesteps // LitJson's long-int handling entirely; the watch only reads // inviteId + name so the string form is invisible to it. entry.Body = System.Text.Json.JsonSerializer.Serialize(new { inviteId = entry.Id, name = roomName, roomInstanceId = roomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture), }); await db.SaveChangesAsync(); await BroadcastMessageAsync(entry); return Ok(new { success = true, messageId = entry.Id }); } /// Mark a single message as read. No-op if not /// addressed to the caller — prevents the sender from "reading" /// the recipient's copy. [HttpPost("markread/{id:long}")] public async Task MarkRead(long id) { var msg = await db.Messages.FirstOrDefaultAsync(m => m.Id == id && m.RecipientPlayerId == Me); if (msg is null) return NotFound(); if (msg.ReadAt is null) { msg.ReadAt = DateTime.UtcNow; await db.SaveChangesAsync(); } return Ok(); } public sealed class SendMultipleBody { public List? ToPlayerIds { get; set; } public int Type { get; set; } public string? Data { get; set; } public long? RoomId { get; set; } } /// POST api/messages/v1/sendMultiple — fan-out a /// message to many recipients. Persists one MessageEntity per /// recipient. Wire shape: {ToPlayerIds (int[]), Type (int), Data (string)}. [HttpPost("/api/messages/v1/sendMultiple")] [Consumes("application/json")] public async Task SendMultiple([FromBody] SendMultipleBody body) { if (body.ToPlayerIds is null || body.ToPlayerIds.Count == 0) return Ok(Array.Empty()); var entries = new List(); foreach (var to in body.ToPlayerIds.Distinct().Where(id => id > 0 && id != Me)) { var entry = new MessageEntity { SenderPlayerId = Me, RecipientPlayerId = to, Body = body.Data ?? string.Empty, Type = body.Type, RoomId = body.RoomId, }; entries.Add(entry); db.Messages.Add(entry); } await db.SaveChangesAsync(); foreach (var entry in entries) { await BroadcastMessageAsync(entry); } return Ok(Array.Empty()); } /// POST api/offlineinvite/v1/send — send an invite /// message to a player who's offline; surfaces in their inbox on /// next login. Wire shape: form-urlencoded RecipientId + /// Data. Idempotent per (sender, recipient, body). [HttpPost("/api/offlineinvite/v1/send")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task OfflineInviteSend( [FromForm(Name = "RecipientId")] long? recipientId, [FromForm(Name = "Data")] string? data) { if (recipientId is not long rid || rid <= 0 || rid == Me) return Ok(new { success = false, error = "invalid_recipient" }); var entry = new MessageEntity { SenderPlayerId = Me, RecipientPlayerId = rid, Body = data ?? string.Empty, Type = 0, }; db.Messages.Add(entry); await db.SaveChangesAsync(); await BroadcastMessageAsync(entry); return Ok(new { success = true, messageId = entry.Id }); } /// GET api/offlineinvite/v1/all — invites /// received while the player was offline. Pulls unread /// MessageEntity rows whose body looks like an invite. [HttpGet("/api/offlineinvite/v1/all")] public async Task OfflineInviteAll() { var rows = await db.Messages .Where(m => m.RecipientPlayerId == Me && m.ReadAt == null && (m.Body.Contains("invite") || m.Body.Contains("event"))) .OrderByDescending(m => m.SentAt) .Take(50) .Select(m => new { InviteId = m.Id, FromPlayerId = (int)m.SenderPlayerId, m.Body, SentAt = m.SentAt, }) .ToListAsync(); return Ok(rows); } /// The boot sequence asks for online friends. We /// derive that from the SignalR hub's connected-player set — /// the response shape is [ {AccountId, Online}, ... ]. [HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")] public async Task FavoriteFriendOnlineStatus() { var friendIds = await db.Relationships .Where(r => r.Status == RelationshipStatus.Friend && (r.RequesterId == Me || r.TargetId == Me)) .Select(r => r.RequesterId == Me ? r.TargetId : r.RequesterId) .ToListAsync(); var online = onlinePresence.OnlinePlayerIds().ToHashSet(); return Ok(friendIds.Select(id => new { AccountId = id, Online = online.Contains(id), })); } /// Wire shape verified against /// Cpp2IL_ISIL/.../RecNet/Message.txt:591-787. /// RecNet.Message.Deserialize calls Util.GetKey /// (throws on miss) for: Id (long), FromPlayerId (int), /// SentTime (DateTime), Type (int — MessageType enum), /// and reads Data (string) via Util.GetKeyOrDefault. /// Optional nullable wrappers: RoomId, PlayerEventId. /// Previous response used {Id, Sender, Recipient, Body, SentAt, ReadAt} /// which crashed at Util.GetKey("FromPlayerId"). public sealed record MessageDto( long Id, long FromPlayerId, DateTime SentTime, int Type, string Data, long? RoomId, long? PlayerEventId); private async Task> ToDtosAsync(List rows) { var instanceIds = rows .Where(m => IsInstanceInviteType(m.Type) && m.RoomId is null && long.TryParse(m.Body, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out _)) .Select(m => long.Parse(m.Body, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture)) .Distinct() .ToList(); var roomByInstance = instanceIds.Count == 0 ? new Dictionary() : await db.PrivateInstances .Where(i => instanceIds.Contains(i.Id)) .Select(i => new { i.Id, i.RoomId }) .ToDictionaryAsync(i => i.Id, i => i.RoomId); return rows.Select(m => { long? roomId = m.RoomId; if (roomId is null && IsInstanceInviteType(m.Type) && long.TryParse(m.Body, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var instanceId) && roomByInstance.TryGetValue(instanceId, out var rid)) { roomId = rid; } return ToDto(m, roomId); }).ToList(); } private static bool IsInstanceInviteType(int type) => type is 6 or 7; /// Push a MessageReceived SignalR notification to BOTH /// the recipient AND the sender. The recipient half is the obvious one /// — that's what fires the watch's OnMessageAdded event and /// gives them the toast / inbox-row update. The sender-half is less /// obvious but equally needed: the watch's Messages.SendMessage /// (Messages.txt:1790-1900) just does ExpectHttpStatusSuccess /// on the response and never touches the local MessageList, /// so without a self-targeted push the sender's chat thread doesn't /// know about its own outgoing message until the next /// RefreshList (which only fires on hub-reconnect or first /// inbox open). Pushing to both sides keeps both watches in sync /// in real time. Dedup happens client-side in /// OnMessageReceived via the "already-in-MessageList by Id" /// check (Messages.txt:2790-2793), so a player who somehow ends up /// as both sender and recipient (admin self-messaging) only sees /// the message once. private async Task BroadcastMessageAsync(MessageEntity entry) { var dto = ToDto(entry); await notifications.NotifyAsync(entry.RecipientPlayerId, PushNotificationId.MessageReceived, dto); if (entry.SenderPlayerId != entry.RecipientPlayerId) { await notifications.NotifyAsync(entry.SenderPlayerId, PushNotificationId.MessageReceived, dto); } } private static MessageDto ToDto(MessageEntity m, long? roomId = null) => new( Id: m.Id, FromPlayerId: m.SenderPlayerId, SentTime: m.SentAt, // Round-trip the stored Type verbatim. Earlier we coerced // Type==0 → 30 for "pre-Type-column rows", but the // Messages.Type Postgres patch (Program.cs:612-614) added // the column with DEFAULT 30 and backfilled every legacy // row to 30, so any Type==0 row in the DB now is a real // GameInvite (e.g. SendInvite above). Coercing it to 30 // routes it through TextMessageController and renders // "Message : " with no Accept button. Type: m.Type, Data: m.Body, RoomId: roomId ?? m.RoomId, PlayerEventId: null); }