using System; using System.Collections.Generic; using System.IO; using System.Linq; using LiteDB; using SystemJsonSerializer = System.Text.Json.JsonSerializer; namespace Mocha2023.Classes.DBs { public static class ChatDB { private static readonly object Sync = new(); private static readonly LiteDatabase Database = new(Path.Combine(Program.dataDir, "DBs", "Chat.db")); private static readonly ILiteCollection Threads = Database.GetCollection("Threads", BsonAutoId.Int64); private static readonly ILiteCollection Messages = Database.GetCollection("Messages", BsonAutoId.Int64); private static readonly ILiteCollection ReadStates = Database.GetCollection("ReadStates"); static ChatDB() { Threads.EnsureIndex(value => value.UpdatedAt); Messages.EnsureIndex(value => value.ThreadId); Messages.EnsureIndex(value => value.CreatedAt); ReadStates.EnsureIndex(value => value.ThreadId); ReadStates.EnsureIndex(value => value.AccountId); } public static int DeveloperThreadCount { get { lock (Sync) return Threads.Count(); } } public static int DeveloperMessageCount { get { lock (Sync) return Messages.Count(); } } public sealed class ChatThread { [BsonId] public long ThreadId { get; set; } public int Type { get; set; } public string? Name { get; set; } public long CreatedByAccountId { get; set; } public List MemberIds { get; set; } = new(); public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } public sealed class ChatMessage { [BsonId] public long MessageId { get; set; } public long ThreadId { get; set; } public long SenderAccountId { get; set; } public string Body { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } public sealed class ChatReadState { [BsonId] public string Id { get; set; } = string.Empty; public long ThreadId { get; set; } public long AccountId { get; set; } public long LastReadMessageId { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } public static List GetThreadsForPlayer(long accountId, int maxCount) { maxCount = Math.Clamp(maxCount, 1, 100); lock (Sync) { List threads = Threads.FindAll() .Where(value => value.MemberIds?.Contains(accountId) == true) .OrderByDescending(value => value.UpdatedAt) .Take(maxCount) .ToList(); foreach (ChatThread thread in threads) EnsureStarterMessageNoLock(thread); return threads; } } public static ChatThread? GetThread(long threadId, long accountId) { lock (Sync) { ChatThread? thread = Threads.FindById(threadId); if (thread?.MemberIds?.Contains(accountId) != true) return null; EnsureStarterMessageNoLock(thread); return thread; } } public static ChatThread? FindThreadWithMembers( long requestingAccountId, IEnumerable requestedMemberIds) { long[] normalized = NormalizeMembers( requestingAccountId, requestedMemberIds); if (normalized.Length < 2) return null; lock (Sync) { return Threads.FindAll().FirstOrDefault(thread => NormalizeMembers(0, thread.MemberIds).SequenceEqual(normalized)); } } public static ChatThread GetOrCreateThread( long requestingAccountId, IEnumerable requestedMemberIds, string? name = null, int type = 0) { long[] normalized = NormalizeMembers( requestingAccountId, requestedMemberIds); if (normalized.Length < 2) { throw new ArgumentException( "A chat thread requires at least two distinct members.", nameof(requestedMemberIds)); } lock (Sync) { ChatThread? existing = Threads.FindAll().FirstOrDefault(thread => NormalizeMembers(0, thread.MemberIds).SequenceEqual(normalized)); if (existing != null) { EnsureStarterMessageNoLock(existing); return existing; } var thread = new ChatThread { ThreadId = GetNextThreadIdNoLock(), Type = type, Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim(), CreatedByAccountId = requestingAccountId, MemberIds = normalized.ToList(), CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; Threads.Insert(thread); EnsureStarterMessageNoLock(thread); return thread; } } public static List GetMessages( long threadId, long requestingAccountId, int maxCount = 50, long? beforeMessageId = null) { maxCount = Math.Clamp(maxCount, 1, 100); lock (Sync) { ChatThread? thread = Threads.FindById(threadId); if (thread?.MemberIds?.Contains(requestingAccountId) != true) return new List(); IEnumerable query = Messages.Find(value => value.ThreadId == threadId); if (beforeMessageId.HasValue && beforeMessageId.Value > 0) query = query.Where(value => value.MessageId < beforeMessageId.Value); return query .OrderByDescending(value => value.MessageId) .Take(maxCount) .OrderBy(value => value.MessageId) .ToList(); } } public static ChatMessage? GetLastMessage(long threadId) { lock (Sync) { return Messages.Find(value => value.ThreadId == threadId) .OrderByDescending(value => value.CreatedAt) .FirstOrDefault(); } } public static List GetMessagesForDeveloper( int maxCount = 100, long? beforeMessageId = null) { maxCount = Math.Clamp(maxCount, 1, 250); lock (Sync) { IEnumerable query = Messages.FindAll(); if (beforeMessageId.HasValue && beforeMessageId.Value > 0) query = query.Where(value => value.MessageId < beforeMessageId.Value); return query .OrderByDescending(value => value.MessageId) .Take(maxCount) .ToList(); } } public static Dictionary GetThreadsForDeveloper( IEnumerable threadIds) { long[] ids = threadIds .Where(value => value > 0) .Distinct() .ToArray(); lock (Sync) { return Threads.FindAll() .Where(value => ids.Contains(value.ThreadId)) .ToDictionary(value => value.ThreadId); } } public static long GetLastReadMessageId(long threadId, long accountId) { lock (Sync) { ChatThread? thread = Threads.FindById(threadId); if (thread?.MemberIds?.Contains(accountId) != true) return 0; return ReadStates.FindById(BuildReadStateId(threadId, accountId)) ?.LastReadMessageId ?? 0; } } public static bool MarkMessageRead( long threadId, long messageId, long accountId) { if (threadId <= 0 || messageId <= 0 || accountId <= 0) return false; lock (Sync) { ChatThread? thread = Threads.FindById(threadId); if (thread?.MemberIds?.Contains(accountId) != true) return false; ChatMessage? message = Messages.FindById(messageId); if (message == null || message.ThreadId != threadId) return false; SetLastReadNoLock(threadId, accountId, messageId); return true; } } public static ChatMessage? AddMessage( long threadId, long senderAccountId, string body) { body = body?.Trim() ?? string.Empty; if (body.Length is < 1 or > 2000 || body.Any(char.IsControl)) return null; lock (Sync) { ChatThread? thread = Threads.FindById(threadId); if (thread?.MemberIds?.Contains(senderAccountId) != true) return null; var message = new ChatMessage { MessageId = GetNextMessageIdNoLock(), ThreadId = threadId, SenderAccountId = senderAccountId, Body = body, CreatedAt = DateTime.UtcNow }; Messages.Insert(message); thread.UpdatedAt = message.CreatedAt; Threads.Update(thread); SetLastReadNoLock(threadId, senderAccountId, message.MessageId); return message; } } public static bool LeaveThread(long threadId, long requestingAccountId) { lock (Sync) { ChatThread? thread = Threads.FindById(threadId); if (thread?.MemberIds?.Contains(requestingAccountId) != true) return false; if (thread.MemberIds.Count <= 2) { Messages.DeleteMany(value => value.ThreadId == threadId); ReadStates.DeleteMany(value => value.ThreadId == threadId); return Threads.Delete(threadId); } thread.MemberIds.Remove(requestingAccountId); ReadStates.Delete(BuildReadStateId(threadId, requestingAccountId)); thread.UpdatedAt = DateTime.UtcNow; return Threads.Update(thread); } } public static bool DeleteThread(long threadId, long requestingAccountId) => LeaveThread(threadId, requestingAccountId); private static string BuildReadStateId(long threadId, long accountId) => $"{threadId}:{accountId}"; private static void SetLastReadNoLock( long threadId, long accountId, long messageId) { string id = BuildReadStateId(threadId, accountId); ChatReadState? state = ReadStates.FindById(id); if (state == null) { state = new ChatReadState { Id = id, ThreadId = threadId, AccountId = accountId, LastReadMessageId = messageId, UpdatedAt = DateTime.UtcNow }; } else { state.LastReadMessageId = Math.Max( state.LastReadMessageId, messageId); state.UpdatedAt = DateTime.UtcNow; } ReadStates.Upsert(state); } private static ChatMessage EnsureStarterMessageNoLock(ChatThread thread) { ChatMessage? existing = Messages .Find(value => value.ThreadId == thread.ThreadId) .OrderBy(value => value.MessageId) .FirstOrDefault(); if (existing != null) return existing; long creatorId = thread.CreatedByAccountId > 0 ? thread.CreatedByAccountId : thread.MemberIds.FirstOrDefault(); string contents = SystemJsonSerializer.Serialize(new { Type = 0, Version = 1, Data = $"Player <@U{creatorId}> started a chat" }); var starter = new ChatMessage { MessageId = GetNextMessageIdNoLock(), ThreadId = thread.ThreadId, SenderAccountId = -5, Body = contents, CreatedAt = thread.CreatedAt == default ? DateTime.UtcNow : thread.CreatedAt }; Messages.Insert(starter); if (creatorId > 0) SetLastReadNoLock(thread.ThreadId, creatorId, starter.MessageId); if (thread.UpdatedAt < starter.CreatedAt) { thread.UpdatedAt = starter.CreatedAt; Threads.Update(thread); } Console.WriteLine( $"[CHAT] backfilled starter message={starter.MessageId} thread={thread.ThreadId}"); return starter; } private static long[] NormalizeMembers( long requestingAccountId, IEnumerable? memberIds) { IEnumerable values = memberIds ?? Array.Empty(); if (requestingAccountId > 0) values = values.Append(requestingAccountId); return values .Where(value => value > 0) .Distinct() .OrderBy(value => value) .ToArray(); } private static long GetNextThreadIdNoLock() => Threads.Count() == 0 ? 1 : Threads.Max(value => value.ThreadId) + 1; private static long GetNextMessageIdNoLock() => Messages.Count() == 0 ? 1 : Messages.Max(value => value.MessageId) + 1; } }