using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Messages; using RadicadeServer.RecNet.Notifications; using System.Text.Encodings.Web; using System.Text.Json; namespace RadicadeServer.API { public enum QueryMode { Latest = 0, NewerThan = 1, OlderThan = 2 } [Route("chat/")] public class ThreadAPI(RecDBContext _db) : Controller { private const int Success = 0; private const int ThreadNotFound = 2; private const int MembershipNotFound = 3; private const long SystemSenderPlayerId = -5; private const int DefaultMessageCount = 50; private const int MaxMessageCount = 100; private const int ChatThreadTypeDefault = 0; [HttpGet("thread/party"), Authorize] [HttpPost("thread/party"), Authorize] public IActionResult GetPartyThread() { return Ok(Array.Empty()); } [HttpGet("thread"), Authorize] public async Task GetThread([FromQuery] int maxCount, [FromQuery] QueryMode mode, [FromQuery] long? messageId = null, [FromQuery] int count = 0) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var threadLimit = count > 0 ? count : maxCount; var responses = new List<(DateTime LastMessageTime, long ThreadId, Dictionary Response)>(); foreach (var thread in await _db.Chats.ToListAsync()) { var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value); if (!playerIds.Contains(userId.Value)) continue; var latest = (thread.GetMessages() ?? new List()) .OrderByDescending(m => m.chatMessageId) .FirstOrDefault(); responses.Add((latest?.timeSent ?? DateTime.MinValue, thread.chatThreadId, ToListResponse(thread, latest, playerIds, userId.Value))); } var orderedResponses = responses .OrderByDescending(x => x.LastMessageTime) .ThenByDescending(x => x.ThreadId) .Select(x => x.Response); if (threadLimit > 0) orderedResponses = orderedResponses.Take(threadLimit); return Ok(orderedResponses.ToList()); } [HttpPost("thread/withmembers"), Authorize] public async Task CreateThreadWithMembers() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var request = await ReadChatThreadRequest(userId.Value); var thread = await FindOrCreateThread(request.PlayerIds, request.ThreadName, userId.Value); return Ok(ToResponse(thread, userId.Value)); } [HttpPost("thread"), Authorize] public async Task CreateThread() { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var request = await ReadChatThreadRequest(userId.Value); var thread = await FindOrCreateThread(request.PlayerIds, request.ThreadName, userId.Value); var posted = false; if (!string.IsNullOrWhiteSpace(request.MessageContents)) { var message = await AddMessageToThread(thread, userId.Value, request.MessageContents); await NotifyChatMessage(thread, message); posted = true; } var chatThreadResponse = ToResponse(thread, userId.Value); return Ok(new Dictionary { ["chatResult"] = posted ? 0 : 1, ["chatThread"] = chatThreadResponse }); } [HttpGet("thread/{threadId:long}"), Authorize] public async Task GetThreadById(long threadId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return NotFound(); var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value); if (!playerIds.Contains(userId.Value)) return NotFound(); var (mode, messageId) = ReadQueryWindow(); var messages = PageMessages(thread.GetMessages() ?? new List(), mode, messageId, ReadMessageCount(DefaultMessageCount)); return Ok(ToResponse(thread, userId.Value, messages, playerIds)); } [HttpGet("thread/{threadId:long}/message"), Authorize] public async Task GetThreadMessages(long threadId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return NotFound(); var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value); if (!playerIds.Contains(userId.Value)) return NotFound(); var (mode, messageId) = ReadQueryWindow(); var messages = PageMessages(thread.GetMessages() ?? new List(), mode, messageId, ReadMessageCount(DefaultMessageCount)); return Ok(ToMessageResponses(messages)); } [HttpPost("thread/{threadId:long}"), Authorize] [HttpPost("thread/{threadId:long}/message"), Authorize] public async Task SendMessageToThread(long threadId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return NotFound(); var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value); if (!playerIds.Contains(userId.Value)) return NotFound(); var request = await ReadChatThreadRequest(userId.Value); var posted = !string.IsNullOrWhiteSpace(request.MessageContents); if (posted) { var message = await AddMessageToThread(thread, userId.Value, request.MessageContents); await NotifyChatMessage(thread, message); } var chatThreadResponse = ToResponse(thread, userId.Value); return Ok(new Dictionary { ["chatResult"] = posted ? 0 : 1, ["chatThread"] = chatThreadResponse }); } [HttpPost("thread/{threadId:long}/rename"), Authorize] public async Task RenameThread(long threadId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return NotFound(); var body = await ReadJsonBody(); var newName = GetStringFromBody(body, "chatThreadName", "ChatThreadName", "threadName", "ThreadName", "name", "Name"); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); newName = GetStringFromForm(form, "chatThreadName", "ChatThreadName", "threadName", "ThreadName", "name", "Name") ?? newName; } if (string.IsNullOrWhiteSpace(newName)) return Ok(ChatResult(4)); thread.chatThreadName = newName.Trim(); await _db.SaveChangesAsync(); var chatThreadResponse = ToResponse(thread, userId.Value); return Ok(new Dictionary { ["chatResult"] = 0, ["chatThread"] = chatThreadResponse }); } [HttpPost("thread/{threadId:long}/member/{playerId:long}"), Authorize] public async Task AddThreadMember(long threadId, long playerId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return NotFound(); if (playerId <= 0) return BadRequest(); var playerIds = thread.GetPlayerIds() ?? new List(); playerIds = playerIds.Where(x => x > 0).Distinct().ToList(); if (!playerIds.Contains(playerId)) { playerIds.Add(playerId); thread.playerIds = playerIds.OrderBy(x => x).ToJson(); await _db.SaveChangesAsync(); } var chatThreadResponse = ToResponse(thread, userId.Value); return Ok(new Dictionary { ["chatResult"] = 0, ["chatThread"] = chatThreadResponse }); } [HttpPut("thread/{threadId:long}/favorite"), Authorize] [HttpPost("thread/{threadId:long}/favorite"), Authorize] [HttpDelete("thread/{threadId:long}/favorite"), Authorize] public async Task FavoriteThread(long threadId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return Ok(ChatResult(2)); var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value); if (!playerIds.Contains(userId.Value)) return Ok(ChatResult(3)); var favorite = Request.Method.Equals("DELETE", StringComparison.OrdinalIgnoreCase) ? false : await ReadBoolValue("isFavorited", "IsFavorited", "favorite", "Favorite", "value", "Value"); var favorited = thread.GetFavoritedBy(); if (favorite) { if (!favorited.Contains(userId.Value)) favorited.Add(userId.Value); } else { favorited.Remove(userId.Value); } thread.SetFavoritedBy(favorited); await _db.SaveChangesAsync(); return Ok(new Dictionary { ["chatResult"] = 0, ["isFavorited"] = favorite }); } [HttpPost("thread/{threadId:long}/snooze"), Authorize] public async Task SnoozeThread(long threadId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return Ok(ChatResult(2)); var snooze = await ReadBoolValue("snooze", "Snooze"); var snoozedUntil = snooze ? DateTime.UtcNow.AddYears(1) : (DateTime?)null; return Ok(new Dictionary { ["chatResult"] = 0, ["snoozedUntil"] = snoozedUntil }); } [HttpPost("thread/{threadId:long}/leave"), Authorize] public async Task LeaveThread(long threadId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return Ok(ChatResult(2)); var playerIds = thread.GetPlayerIds() ?? new List(); playerIds = playerIds.Where(x => x != userId.Value).Distinct().OrderBy(x => x).ToList(); thread.playerIds = playerIds.ToJson(); await _db.SaveChangesAsync(); if (playerIds.Count > 0) await AddMessageToThread(thread, SystemSenderPlayerId, MessageEnvelope($"Player <@U{userId.Value}> left")); return Ok(ChatResult(0)); } [HttpPost("thread/{threadId:long}/message/{messageId:long}/read"), Authorize] public async Task ReadThreadMessage(long threadId, long messageId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return Ok(ChatResult(ThreadNotFound)); var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value); if (!playerIds.Contains(userId.Value)) return Ok(ChatResult(MembershipNotFound)); if (messageId > thread.lastReadMessageId) { thread.lastReadMessageId = messageId; await _db.SaveChangesAsync(); } return Ok(ChatResult(Success)); } [HttpPost("thread/{threadId:long}/read"), Authorize] public async Task ReadThread(long threadId) { var userId = User.GetUserId(); if (userId == null) return Unauthorized(); var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId); if (thread == null) return Ok(ChatResult(2)); var messageId = await ReadLongValue("messageId", "MessageId", "lastReadMessageId", "LastReadMessageId"); if (messageId.HasValue) { thread.lastReadMessageId = messageId.Value; await _db.SaveChangesAsync(); } return Ok(ChatResult(0)); } private async Task ReadChatThreadRequest(long userId) { var body = await ReadJsonBody(); var playerIds = new List { userId }; AddIdsFromBody(body, playerIds); AddIdsFromQuery(Request.Query, playerIds); var threadName = GetStringFromBody(body, "chatThreadName", "ChatThreadName", "threadName", "ThreadName", "name", "Name") ?? ""; var messageContents = GetStringFromBody(body, "messageContents", "MessageContents", "contents", "Contents", "message", "Message") ?? ""; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); AddIdsFromForm(form, playerIds); threadName = GetStringFromForm(form, "chatThreadName", "ChatThreadName", "threadName", "ThreadName", "name", "Name") ?? threadName; messageContents = GetStringFromForm(form, "messageContents", "MessageContents", "contents", "Contents", "message", "Message") ?? messageContents; } var normalizedPlayerIds = playerIds .Where(x => x > 0) .Distinct() .OrderBy(x => x) .ToList(); return new ChatThreadRequest(normalizedPlayerIds, threadName, messageContents); } private async Task FindOrCreateThread(List playerIds, string threadName, long callerId) { var thread = await FindThread(playerIds); if (thread == null) { thread = new ChatThread { chatThreadName = string.IsNullOrWhiteSpace(threadName) ? "" : threadName, lastReadMessageId = 0, messages = "[]", playerIds = playerIds.ToJson() }; _db.Chats.Add(thread); await _db.SaveChangesAsync(); var other = playerIds.FirstOrDefault(x => x != callerId); await AddMessageToThread(thread, SystemSenderPlayerId, ChatStartedNotice(other != 0 ? other : callerId)); } return thread; } private static string MessageEnvelope(string text) => JsonSerializer.Serialize(new Dictionary { ["Type"] = 0, ["Version"] = 1, ["Data"] = text }, EnvelopeJsonOptions); private static readonly JsonSerializerOptions EnvelopeJsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private static string ChatStartedNotice(long playerId) => MessageEnvelope($"Player <@U{playerId}> started a chat"); private async Task AddMessageToThread(ChatThread thread, long userId, string messageContents) { messageContents = await ResolvePhotoPlaceholder(userId, messageContents); var messages = thread.GetMessages() ?? new List(); var nextMessageId = messages.Count == 0 ? 1 : messages.Max(x => x.chatMessageId) + 1; var message = new ChatMessage { chatMessageId = nextMessageId, chatThreadeId = thread.chatThreadId, senderPlayerId = userId, timeSent = DateTime.UtcNow, contents = messageContents }; messages.Add(message); thread.lastReadMessageId = nextMessageId; thread.messages = messages.ToJson(); await _db.SaveChangesAsync(); return message; } private async Task ResolvePhotoPlaceholder(long userId, string messageContents) { if (userId <= 0) return messageContents; if (!ChatPhotoMessages.IsPhotoEnvelope(messageContents)) return messageContents; if (!ChatPhotoMessages.HasUnresolvedImageReference(messageContents)) return messageContents; string source; long? imageId = null; if (RecentImageReferences.TryGet(userId, out var referenced)) { imageId = referenced; source = "the image metadata it looked up"; } else { var cutoff = DateTime.Now - ChatPhotoMessages.UploadWindow; var recentImage = await _db.Images.AsNoTracking() .Where(image => image.OwnerId == userId && image.CreatedAt >= cutoff) .OrderByDescending(image => image.CreatedAt) .ThenByDescending(image => image.Id) .FirstOrDefaultAsync(); imageId = recentImage?.Id; source = $"its upload {recentImage?.Name}"; } if (imageId == null) return messageContents; var resolved = ChatPhotoMessages.ResolveImageReference(messageContents, imageId.Value); return resolved; } private async Task NotifyChatMessage(ChatThread thread, ChatMessage message) { var notificationMessage = ToMessageResponse(message); var playerIds = (thread.GetPlayerIds() ?? new List()) .Where(x => x > 0) .Distinct() .ToList(); foreach (var playerId in playerIds) { await Notifications.Notify.SendNotify(playerId, new Notification { Id = nameof(NotificationType.ChatMessageReceived), Msg = notificationMessage }); } } private async Task ReadJsonBody() { if (Request.ContentLength == 0) return null; if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) != true) return null; try { return await JsonSerializer.DeserializeAsync(Request.Body); } catch { return null; } } private async Task ReadBoolValue(params string[] keys) { var body = await ReadJsonBody(); var bodyValue = GetBoolFromBody(body, keys); if (bodyValue.HasValue) return bodyValue.Value; foreach (var key in keys) { if (Request.Query.TryGetValue(key, out var queryValue) && bool.TryParse(queryValue.FirstOrDefault(), out var queryBool)) return queryBool; } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var key in keys) { if (form.TryGetValue(key, out var formValue) && bool.TryParse(formValue.FirstOrDefault(), out var formBool)) return formBool; } } return false; } private async Task ReadLongValue(params string[] keys) { var body = await ReadJsonBody(); var bodyValue = GetLongFromBody(body, keys); if (bodyValue.HasValue) return bodyValue.Value; foreach (var key in keys) { if (Request.Query.TryGetValue(key, out var queryValue) && long.TryParse(queryValue.FirstOrDefault(), out var queryLong)) return queryLong; } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var key in keys) { if (form.TryGetValue(key, out var formValue) && long.TryParse(formValue.FirstOrDefault(), out var formLong)) return formLong; } } return null; } private async Task FindThread(List playerIds) { var threads = await _db.Chats.ToListAsync(); return threads.FirstOrDefault(thread => { var existingPlayerIds = thread.GetPlayerIds() ?? new List(); return existingPlayerIds .Where(x => x > 0) .Distinct() .OrderBy(x => x) .SequenceEqual(playerIds); }); } private static Dictionary ToResponse(ChatThread thread, long userId, int pageSize = DefaultMessageCount) { var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId); var messages = PageMessages(thread.GetMessages() ?? new List(), QueryMode.Latest, null, pageSize); return ToResponse(thread, userId, messages, playerIds); } private static Dictionary ToResponse(ChatThread thread, long userId, List messages, List playerIds) { playerIds = NormalizePlayerIds(playerIds, userId); var messageResponses = ToMessageResponses(messages); return new Dictionary { ["chatThreadId"] = thread.chatThreadId, ["playerIds"] = playerIds, ["lastReadMessageId"] = thread.lastReadMessageId, ["chatThreadName"] = ThreadName(thread), ["chatThreadType"] = ChatThreadTypeDefault, ["snoozedUntil"] = null, ["isFavorited"] = thread.GetFavoritedBy().Contains(userId), ["messages"] = messageResponses }; } private static Dictionary ToListResponse(ChatThread thread, ChatMessage? latestMessage, List playerIds, long viewerId) { var latest = latestMessage == null ? null : ToMessageResponse(latestMessage); return new Dictionary { ["latestMessage"] = latest, ["chatThreadId"] = thread.chatThreadId, ["playerIds"] = playerIds, ["lastReadMessageId"] = thread.lastReadMessageId, ["chatThreadName"] = ThreadName(thread), ["chatThreadType"] = ChatThreadTypeDefault, ["snoozedUntil"] = null, ["isFavorited"] = thread.GetFavoritedBy().Contains(viewerId) }; } private static string MessageContents(string? contents) { var raw = contents ?? ""; if (raw.Length == 0) return MessageEnvelope(""); var trimmed = raw.TrimStart(); if (trimmed.StartsWith("{", StringComparison.Ordinal)) { try { using var parsed = System.Text.Json.JsonDocument.Parse(raw); if (parsed.RootElement.ValueKind == JsonValueKind.Object) return raw; } catch (System.Text.Json.JsonException) { } } return MessageEnvelope(raw); } private static List NormalizePlayerIds(IEnumerable? playerIds, long userId) { var normalized = (playerIds ?? Enumerable.Empty()) .Where(x => x > 0) .Distinct() .OrderBy(x => x) .ToList(); if (normalized.Count == 0) normalized.Add(userId); return normalized; } private static string ThreadName(ChatThread thread) => string.IsNullOrWhiteSpace(thread.chatThreadName) ? "" : thread.chatThreadName; private static List> ToMessageResponses(IEnumerable messages) { return messages.Select(ToMessageResponse).ToList(); } private static Dictionary ToMessageResponse(ChatMessage message) { var contents = MessageContents(message.contents); return new Dictionary { ["chatMessageId"] = message.chatMessageId, ["chatThreadId"] = message.chatThreadeId, ["senderPlayerId"] = message.senderPlayerId, ["timeSent"] = message.timeSent, ["contents"] = contents, ["moderationState"] = 0 }; } private static void AddIdsFromBody(JsonElement? body, List playerIds) { if (!body.HasValue || body.Value.ValueKind != JsonValueKind.Object) return; foreach (var key in new[] { "ids", "Ids", "playerIds", "PlayerIds", "recipientPlayerIds", "RecipientPlayerIds", "memberIds", "MemberIds", "memberPlayerIds", "MemberPlayerIds", "playerId", "PlayerId", "toPlayerId", "ToPlayerId" }) { if (body.Value.TryGetProperty(key, out var value)) AddIdsFromJson(value, playerIds); } } private static void AddIdsFromJson(JsonElement value, List playerIds) { switch (value.ValueKind) { case JsonValueKind.Array: foreach (var item in value.EnumerateArray()) AddIdsFromJson(item, playerIds); break; case JsonValueKind.Number: if (value.TryGetInt64(out var id) && id > 0) playerIds.Add(id); break; case JsonValueKind.String: AddIdsFromStrings(new[] { value.GetString() ?? "" }, playerIds); break; } } private static void AddIdsFromQuery(IQueryCollection query, List playerIds) { foreach (var key in new[] { "ids", "Ids", "playerIds", "PlayerIds", "recipientPlayerIds", "RecipientPlayerIds", "memberIds", "MemberIds", "memberPlayerIds", "MemberPlayerIds", "playerId", "PlayerId", "toPlayerId", "ToPlayerId" }) { if (query.TryGetValue(key, out var values)) AddIdsFromStrings(values, playerIds); } } private static void AddIdsFromForm(IFormCollection form, List playerIds) { foreach (var key in new[] { "ids", "Ids", "playerIds", "PlayerIds", "recipientPlayerIds", "RecipientPlayerIds", "memberIds", "MemberIds", "memberPlayerIds", "MemberPlayerIds", "playerId", "PlayerId", "toPlayerId", "ToPlayerId" }) { if (form.TryGetValue(key, out var values)) AddIdsFromStrings(values, playerIds); } } private static void AddIdsFromStrings(IEnumerable values, List playerIds) { foreach (var value in values) { if (string.IsNullOrWhiteSpace(value)) continue; foreach (var part in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var clean = part.Trim('[', ']', '"', '\''); if (long.TryParse(clean, out var id) && id > 0) playerIds.Add(id); } } } private static string? GetStringFromBody(JsonElement? body, params string[] keys) { if (!body.HasValue || body.Value.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) { if (body.Value.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) return value.GetString(); } return null; } private static bool? GetBoolFromBody(JsonElement? body, params string[] keys) { if (!body.HasValue || body.Value.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) { if (!body.Value.TryGetProperty(key, out var value)) continue; if (value.ValueKind == JsonValueKind.True) return true; if (value.ValueKind == JsonValueKind.False) return false; if (value.ValueKind == JsonValueKind.String && bool.TryParse(value.GetString(), out var parsed)) return parsed; } return null; } private static long? GetLongFromBody(JsonElement? body, params string[] keys) { if (!body.HasValue || body.Value.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) { if (!body.Value.TryGetProperty(key, out var value)) continue; if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var parsedNumber)) return parsedNumber; if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out var parsedString)) return parsedString; } return null; } private static string? GetStringFromForm(IFormCollection form, params string[] keys) { foreach (var key in keys) { if (form.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value.FirstOrDefault())) return value.FirstOrDefault(); } return null; } private static int ChatResult(int result) => result; private int ReadMessageCount(int fallback) { foreach (var key in new[] { "MessageCount", "messageCount", "count", "Count" }) { if (Request.Query.TryGetValue(key, out var values) && int.TryParse(values.FirstOrDefault(), out var parsed)) return Math.Clamp(parsed, 1, MaxMessageCount); } return Math.Clamp(fallback, 1, MaxMessageCount); } private (QueryMode Mode, long? MessageId) ReadQueryWindow() { var mode = QueryMode.Latest; foreach (var key in new[] { "Mode", "mode" }) { if (Request.Query.TryGetValue(key, out var values) && TryParseQueryMode(values.FirstOrDefault(), out var parsed)) { mode = parsed; break; } } long? messageId = null; foreach (var key in new[] { "ReferenceMessageId", "referenceMessageId", "MessageId", "messageId", "LastMessageId", "lastMessageId" }) { if (Request.Query.TryGetValue(key, out var values) && long.TryParse(values.FirstOrDefault(), out var parsed)) { messageId = parsed; break; } } return (mode, messageId); } private static bool TryParseQueryMode(string? raw, out QueryMode mode) { mode = QueryMode.Latest; if (string.IsNullOrWhiteSpace(raw)) return false; if (int.TryParse(raw, out var numeric) && Enum.IsDefined(typeof(QueryMode), numeric)) { mode = (QueryMode)numeric; return true; } return Enum.TryParse(raw, ignoreCase: true, out mode); } private static List PageMessages(List stored, QueryMode mode, long? messageId, int pageSize) { var window = mode switch { QueryMode.NewerThan when messageId.HasValue => stored.Where(m => m.chatMessageId > messageId.Value), QueryMode.OlderThan when messageId.HasValue => stored.Where(m => m.chatMessageId < messageId.Value), _ => stored }; return window .OrderByDescending(m => m.chatMessageId) .Take(Math.Clamp(pageSize, 1, MaxMessageCount)) .ToList(); } private sealed record ChatThreadRequest(List PlayerIds, string ThreadName, string MessageContents); } }