Files
DorkNet/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs
T

666 lines
30 KiB
C#

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;
/// <summary>
/// api.rec.net/api/messages/v2/* — direct-message inbox API. Backed
/// by <see cref="MessageEntity"/>. Sends fan out a SignalR push
/// (<c>MessageReceived</c>) so the recipient's watch refreshes
/// immediately.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class MessagesController(
DorkNetDbContext db,
NotificationService notifications,
PrivateInstanceService privateInstances,
PlayerPresenceService presence,
ServerSettingsService serverSettings,
ILogger<MessagesController> logger) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>List the caller's inbox, newest-first. Response is a
/// flat array of <see cref="MessageDto"/>; the watch groups by
/// sender locally. v1/v3 paths share the same handler — the watch
/// uses whichever URL its <c>RecNet.Messages</c> static helper
/// built from <c>GameConfig.MessageServiceVersion</c>.</summary>
[HttpGet("get")]
[HttpGet("/api/messages/v1/get")]
[HttpGet("/api/messages/v3/get")]
public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me)
.Where(m => m.Type >= 0)
// 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));
}
/// <summary>GET <c>api/messages/v1/unread</c> — 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.</summary>
[HttpGet("/api/messages/v1/unread")]
public async Task<ActionResult<List<MessageDto>>> Unread()
{
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null && m.Type >= 0)
// 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<long>? MessageIds { get; set; }
}
/// <summary>POST <c>api/messages/v3/delete</c> — delete one or
/// more messages addressed to the caller. The 2020 watch's inbox
/// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural
/// array, verified from the live trace where the body was
/// <c>{"MessageIds":[8]}</c> against <c>/api/messages/v3/delete</c>);
/// some older / SPA call sites still post <c>id</c> or
/// <c>messageId</c> as a form/query. Accept all three shapes and
/// scope every delete to <c>RecipientPlayerId == Me</c> so the
/// sender can't unsend. Game invites are tombstoned instead of
/// hard-deleted because the watch accepts an invite by firing this
/// delete request and <c>/goto/invite/{id}</c> in parallel.</summary>
[HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v1/delete")]
[Consumes("application/json")]
public async Task<IActionResult> DeleteJson([FromBody] DeleteRequest body)
{
var ids = new List<long>();
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<IActionResult> DeleteForm(
[FromForm(Name = "id")] long? formId,
[FromForm(Name = "messageId")] long? formMessageId,
[FromForm(Name = "messageIds")] string? messageIdsCsv)
{
var ids = new List<long>();
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<IActionResult> DeleteByIds(List<long> ids)
{
ids = ids.Distinct().ToList();
if (ids.Count == 0) return BadRequest(new { error = "missing_id" });
var inviteRows = await db.Messages
.Where(m => ids.Contains(m.Id)
&& m.RecipientPlayerId == Me
&& (m.Type == 6 || m.Type == 7))
.ToListAsync();
var now = DateTime.UtcNow;
foreach (var invite in inviteRows)
{
invite.Type = -invite.Type;
invite.ReadAt ??= now;
}
var hardDeleted = await db.Messages
.Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me)
.Where(m => m.Type != 6 && m.Type != 7 && m.Type != -6 && m.Type != -7)
.ExecuteDeleteAsync();
if (inviteRows.Count > 0)
await db.SaveChangesAsync();
return Ok(new { success = true, error = "", deleted = hardDeleted + inviteRows.Count });
}
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk
/// mark-as-read for every unread message addressed to the caller.
/// Optional <c>ids</c> CSV scopes to specific message ids.</summary>
[HttpPost("/api/messages/v3/markRead")]
[HttpPost("/api/messages/v1/markRead")]
public async Task<IActionResult> MarkReadBulk([FromForm(Name = "ids")] string? ids)
{
IQueryable<MessageEntity> 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);
/// <summary>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 (<see cref="SendFormMessage"/>); the
/// <c>[Consumes]</c> 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.</summary>
[HttpPost("send")]
[HttpPost("/api/messages/v1/send")]
[Consumes("application/json")]
public async Task<ActionResult<MessageDto>> 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));
}
/// <summary>Form-urlencoded variant — what the 2020 watch actually
/// posts when the user taps "Send Join Request" or any other
/// <c>RecNet.Messages.SendMessage(player, type, data)</c> caller.
/// Wire shape (verified against client log <c>contentType=
/// application/x-www-form-urlencoded</c>, body <c>ToPlayerId=…&amp;
/// Type=10&amp;Data=</c>): the <c>Type</c> integer is a
/// <c>RecNet.Message.MessageType</c> enum value
/// (<c>Cpp2IL_CS/.../RecNet/Message.cs</c>) — 10 =
/// RequestGameInvite, 30 = TextMessage, etc. Empty <c>Data</c> is
/// allowed for control-channel types (join request, declines)
/// where the message itself is the signal.</summary>
[HttpPost("send")]
[HttpPost("/api/messages/v1/send")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<MessageDto>> 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));
}
/// <summary>POST <c>invite</c> — handles the watch's
/// <c>RecNet.Messages.SendGameInvite(playerId, roomInstanceId)</c>
/// (verified at <c>Cpp2IL_ISIL/.../Messages.txt:613-773</c>): form
/// fields <c>playerId</c> (recipient) + <c>roomInstanceId</c>
/// (the inviter's private match id). Two side-effects:
/// 1. Add the recipient to the invite list for that instance so
/// <c>POST /goto/instance/{id}</c> 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.</summary>
[HttpPost("/invite")]
[HttpPost("/api/invite")]
[HttpPost("/api/messages/v1/invite")]
[HttpPost("/api/messages/v2/invite")]
[HttpPost("/api/messages/v3/invite")]
public async Task<ActionResult> 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" });
// Resolve which room the invite is for. Two code paths:
//
// PRIVATE instance: the sender created an invite-only match
// (via /goto/room/X?CreatePrivateInstance=1). The
// roomInstanceId the watch sends maps to a registered
// PrivateInstance and only the owner is allowed to invite
// anyone in. Enforces our private-match access control —
// no random can DM their way past the invite list.
//
// PUBLIC room: the sender is in a public room / a dorm /
// any non-private room. RoomInstanceId is just
// roomId*100_000 — synthetic, never registered. We have to
// resolve which Room they're in via PlayerPresenceService
// (heartbeats keep this up to date) so we can stamp RoomId
// on the message and the recipient's /goto/invite/{N} path
// knows where to send them. No ownership check applies for
// public rooms.
long resolvedRoomId;
string roomName;
var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is not null)
{
if (inst.OwnerPlayerId != Me) return Forbid();
resolvedRoomId = inst.RoomId;
roomName = await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
}
else
{
// Public-room path. Trust the sender's current presence —
// the watch only fires SendInvite from the social menu
// while you're in a room, and heartbeats refresh presence
// every few seconds. We DON'T trust the raw roomInstanceId
// from the form because it's user-controlled and a
// malicious mod could send a fabricated one.
var senderPresence = presence.GetRoom(Me);
if (senderPresence is null || senderPresence.RoomId <= 0)
{
logger.LogWarning(
"[invite] from={From} to={To} instance={Instance}: no private instance AND no presence room — rejecting",
Me, playerId, roomInstanceId);
return BadRequest(new { error = "sender_not_in_a_room", roomInstanceId });
}
resolvedRoomId = senderPresence.RoomId;
roomName = !string.IsNullOrWhiteSpace(senderPresence.Name)
? senderPresence.Name
: await db.Rooms
.Where(r => r.Id == senderPresence.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Room";
}
// 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 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 = resolvedRoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
// Only pin the invitee row for PRIVATE instances — public-room
// invites have no registered instance to track invitees against.
// GoToInvite handles the missing-mapping case by falling through
// to the message's RoomId + BuildResponseAsync(roomName).
if (inst is not null)
{
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=<null>`
// `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 });
}
/// <summary>Mark a single message as read. No-op if not
/// addressed to the caller — prevents the sender from "reading"
/// the recipient's copy.</summary>
[HttpPost("markread/{id:long}")]
public async Task<ActionResult> 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<long>? ToPlayerIds { get; set; }
public int Type { get; set; }
public string? Data { get; set; }
public long? RoomId { get; set; }
}
/// <summary>POST <c>api/messages/v1/sendMultiple</c> — fan-out a
/// message to many recipients. Persists one MessageEntity per
/// recipient. Wire shape: <c>{ToPlayerIds (int[]), Type (int), Data (string)}</c>.</summary>
[HttpPost("/api/messages/v1/sendMultiple")]
[Consumes("application/json")]
public async Task<IActionResult> SendMultiple([FromBody] SendMultipleBody body)
{
if (body.ToPlayerIds is null || body.ToPlayerIds.Count == 0)
return Ok(Array.Empty<object>());
var entries = new List<MessageEntity>();
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<object>());
}
// NOTE: There is intentionally NO separate handler for
// `/api/messages/v2/send`. The controller route is
// `api/[controller]/v2`, so SendFormMessage's relative
// `[HttpPost("send")]` already binds `api/messages/v2/send` for
// form posts (and SendMessage binds it for JSON, disambiguated by
// [Consumes]). A previous standalone `SendSingle` action with an
// explicit `[HttpPost("/api/messages/v2/send")]` duplicated that
// binding, which made every form post to v2/send match two
// endpoints → AmbiguousMatchException (500). That broke the very
// thing it was meant to add: the watch's PartyUpRequest /
// "send join request" (MessageType 120), which posts the same
// ToPlayerId/Type/Data form shape SendFormMessage already accepts.
// Do not re-add a v2/send action; extend SendFormMessage instead.
/// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite
/// message to a player who's offline; surfaces in their inbox on
/// next login. Wire shape: form-urlencoded <c>RecipientId</c> +
/// <c>Data</c>. Idempotent per (sender, recipient, body).</summary>
[HttpPost("/api/offlineinvite/v1/send")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> 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 });
}
/// <summary>GET <c>api/offlineinvite/v1/all</c> — invites
/// received while the player was offline. Pulls unread
/// MessageEntity rows whose body looks like an invite.</summary>
[HttpGet("/api/offlineinvite/v1/all")]
public async Task<IActionResult> 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);
}
/// <summary>The boot sequence asks for online friends. We
/// derive that from the SignalR hub's connected-player set —
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.
///
/// Both <c>favoriteFriendOnlineStatus</c> and the more general
/// <c>onlineStatus</c> URLs alias to this handler since they
/// share the wire shape; pre-2026-05-25 the onlineStatus route
/// was wrongly aliased to GetMessages and returned the inbox
/// list instead, breaking the friends-online HUD.</summary>
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult> FavoriteFriendOnlineStatus()
{
// Honors the global-friends toggle (everyone is a friend when on).
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me);
return Ok(friendIds.Select(id => new
{
AccountId = id,
Online = presence.IsRecentlyActive(id),
}));
}
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Message.txt:591-787</c>.
/// <c>RecNet.Message.Deserialize</c> calls <c>Util.GetKey</c>
/// (throws on miss) for: <c>Id</c> (long), <c>FromPlayerId</c> (int),
/// <c>SentTime</c> (DateTime), <c>Type</c> (int — MessageType enum),
/// and reads <c>Data</c> (string) via <c>Util.GetKeyOrDefault</c>.
/// Optional nullable wrappers: <c>RoomId</c>, <c>PlayerEventId</c>.
/// Previous response used <c>{Id, Sender, Recipient, Body, SentAt, ReadAt}</c>
/// which crashed at <c>Util.GetKey("FromPlayerId")</c>.</summary>
public sealed record MessageDto(
long Id,
long FromPlayerId,
DateTime SentTime,
int Type,
string Data,
long? RoomId,
long? PlayerEventId);
private async Task<List<MessageDto>> ToDtosAsync(List<MessageEntity> 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<long, long>()
: 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;
/// <summary>Push a <c>MessageReceived</c> SignalR notification to BOTH
/// the recipient AND the sender. The recipient half is the obvious one
/// — that's what fires the watch's <c>OnMessageAdded</c> event and
/// gives them the toast / inbox-row update. The sender-half is less
/// obvious but equally needed: the watch's <c>Messages.SendMessage</c>
/// (Messages.txt:1790-1900) just does <c>ExpectHttpStatusSuccess</c>
/// on the response and never touches the local <c>MessageList</c>,
/// so without a self-targeted push the sender's chat thread doesn't
/// know about its own outgoing message until the next
/// <c>RefreshList</c> (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
/// <c>OnMessageReceived</c> 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.</summary>
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 : <instanceId>" with no Accept button.
Type: m.Type,
Data: m.Body,
RoomId: roomId ?? m.RoomId,
PlayerEventId: null);
}