using LiteDB; using Newtonsoft.Json; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes.db_class; using static Campus_live_rewrite.api.server.Classes.db_class.ChatDBClasses; namespace Campus_live_rewrite.api.dbs { /// /// Chat threads + messages (Data/DBs/Chat.db). One thread per exact member set for DMs and /// groups (fetch-or-create, like the reference's GetChatBetweenPlayers), party threads as /// type-1 threads, and per-member read / snooze / favorite state. Every write that another /// member should see is pushed over the notifications hub as ChatMessageReceived so the /// conversation (and the in-room bubble) updates without polling. /// public class ChatDB { public const int SystemSenderId = -5; // The client's ChatThreadType: 0 Player (DM / group), 1 Club, 2 Party. Party threads were // stored and served as 1 - a CLUB thread to the client, which is why the party's chat // rendered as an ordinary conversation named after the other member. public const int ThreadTypeUser = 0; public const int ThreadTypeClub = 1; public const int ThreadTypeParty = 2; public const int MaxThreadMembers = 50; public static LiteDatabase ChatDBFile = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Chat.db"); private static ILiteCollection Threads => ChatDBFile.GetCollection("threads"); private static ILiteCollection Messages => ChatDBFile.GetCollection("messages"); private static readonly object _idLock = new(); static ChatDB() { Threads.EnsureIndex(x => x.ChatThreadId, unique: true); Threads.EnsureIndex(x => x.PlayerIds); Messages.EnsureIndex(x => x.ChatThreadId); Messages.EnsureIndex(x => x.ChatMessageId, unique: true); // One-time migration: party threads created while the type was mis-numbered (1) become 2. // No club threads exist in this store (clubs chat through their own service), so every // type-1 thread here is a party. try { int n = 0; foreach (var t in Threads.Find(x => x.ChatThreadType == ThreadTypeClub).ToList()) { t.ChatThreadType = ThreadTypeParty; Threads.Update(t); n++; } if (n > 0) Console.WriteLine($"[chat] migrated {n} party thread(s) to ChatThreadType {ThreadTypeParty}"); } catch (Exception ex) { Console.WriteLine($"[chat] party type migration failed: {ex.Message}"); } } private static long NextThreadId() { var last = Threads.Query().OrderByDescending(x => x.ChatThreadId).Limit(1).FirstOrDefault(); return (last?.ChatThreadId ?? 0) + 1; } private static long NextMessageId() { var last = Messages.Query().OrderByDescending(x => x.ChatMessageId).Limit(1).FirstOrDefault(); return (last?.ChatMessageId ?? 0) + 1; } public static string StartedChatContents(ulong by) => JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = $"Player <@U{by}> started a chat" }); // ---------------- threads ---------------- /// Create a thread. type 0 = DM/group (posts the "started a chat" notice), 1 = party. public static ChatThread CreateThread(List memberIds, ulong creatorid, string? name = null, int type = ThreadTypeUser) { ChatThread thread; lock (_idLock) { thread = new ChatThread { ChatThreadId = NextThreadId(), PlayerIds = memberIds.Distinct().ToList(), ChatThreadName = name ?? "", ChatThreadType = type, CreatorPlayerId = creatorid, CreatedAt = DateTime.UtcNow, LastActivityAt = DateTime.UtcNow, IsFavorited = false, SnoozedUntil = null, LastReadMessageId = 0, Messages = new List() }; Threads.Insert(thread); } if (type == ThreadTypeUser) AddMessage(thread.ChatThreadId, SystemSenderId, StartedChatContents(creatorid)); return GetThread(thread.ChatThreadId)!; } /// /// The existing type-0 thread whose members are EXACTLY this set, else null. Keeps one /// conversation per pair/group: every "message X" used to mint a brand-new thread. /// public static ChatThread? FindThreadWithExactMembers(List memberIds) { var want = memberIds.Distinct().OrderBy(x => x).ToList(); if (want.Count == 0) return null; ulong probe = want[0]; var candidates = Threads.Find(x => x.PlayerIds.Contains(probe) && x.ChatThreadType == ThreadTypeUser).ToList(); var hit = candidates .Where(t => t.PlayerIds != null && t.PlayerIds.Distinct().OrderBy(x => x).SequenceEqual(want)) .OrderByDescending(t => t.LastActivityAt ?? DateTime.MinValue).ThenByDescending(t => t.ChatThreadId) .FirstOrDefault(); return hit == null ? null : GetThread(hit.ChatThreadId); } public static ChatThread GetOrCreateThreadWithMembers(List memberIds, ulong creatorId) => FindThreadWithExactMembers(memberIds) ?? CreateThread(memberIds, creatorId); /// The caller's party thread (type 1, most recent) or null. public static ChatThread? GetPartyThreadFor(ulong playerId) { var t = Threads.Find(x => x.PlayerIds.Contains(playerId) && x.ChatThreadType == ThreadTypeParty) .OrderByDescending(x => x.LastActivityAt ?? DateTime.MinValue).ThenByDescending(x => x.ChatThreadId).FirstOrDefault(); return t == null ? null : GetThread(t.ChatThreadId); } public static ChatThread? GetThread(long threadId) { var thread = Threads.FindOne(x => x.ChatThreadId == threadId); if (thread == null) return null; thread.Messages = Messages.Find(x => x.ChatThreadId == threadId).OrderBy(x => x.ChatMessageId).ToList(); thread.MemberStates ??= new(); thread.PlayerIds ??= new(); return thread; } /// Threads the player is on, most recently active first, each with only its latest message. public static List GetThreadsForPlayer(ulong playerId, int maxCount) { // Most recently active first; threads from before LastActivityAt existed sort by id. var list = Threads.Find(x => x.PlayerIds.Contains(playerId)) .OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt ?? DateTime.MinValue) .ThenByDescending(x => x.ChatThreadId) .Take(maxCount).ToList(); foreach (var t in list) { t.Messages = Messages.Find(x => x.ChatThreadId == t.ChatThreadId).OrderByDescending(x => x.ChatMessageId).Take(1).ToList(); t.MemberStates ??= new(); t.PlayerIds ??= new(); } return list; } public static bool IsMember(long threadId, ulong playerId) { var t = Threads.FindOne(x => x.ChatThreadId == threadId); return t?.PlayerIds != null && t.PlayerIds.Contains(playerId); } public static ChatThread? AddMember(long threadId, ulong playerId) { var t = Threads.FindOne(x => x.ChatThreadId == threadId); if (t == null) return null; t.PlayerIds ??= new(); if (!t.PlayerIds.Contains(playerId)) { t.PlayerIds.Add(playerId); t.LastActivityAt = DateTime.UtcNow; Threads.Update(t); } return GetThread(threadId); } public static ChatThread? RemoveMember(long threadId, ulong playerId) { var t = Threads.FindOne(x => x.ChatThreadId == threadId); if (t == null) return null; t.PlayerIds ??= new(); if (t.PlayerIds.Remove(playerId)) { t.LastActivityAt = DateTime.UtcNow; Threads.Update(t); } return GetThread(threadId); } public static ChatThread? SetName(long threadId, string name) { var t = Threads.FindOne(x => x.ChatThreadId == threadId); if (t == null) return null; t.ChatThreadName = name ?? ""; Threads.Update(t); return GetThread(threadId); } // ---------------- per-member state ---------------- public static ChatMemberState StateFor(ChatThread t, ulong playerId) { t.MemberStates ??= new(); if (t.MemberStates.TryGetValue(playerId.ToString(), out var s) && s != null) return s; // No entry yet: fall back to the old shared thread-level values. return new ChatMemberState { LastReadMessageId = t.LastReadMessageId, SnoozedUntil = t.SnoozedUntil, IsFavorited = t.IsFavorited }; } public static void UpdateState(long threadId, ulong playerId, Action change) { var t = Threads.FindOne(x => x.ChatThreadId == threadId); if (t == null) return; t.MemberStates ??= new(); string key = playerId.ToString(); if (!t.MemberStates.TryGetValue(key, out var s) || s == null) s = new ChatMemberState { LastReadMessageId = t.LastReadMessageId, SnoozedUntil = t.SnoozedUntil, IsFavorited = t.IsFavorited }; change(s); t.MemberStates[key] = s; Threads.Update(t); } /// Move the read pointer forward (never back, never past the latest message). public static void MarkRead(long threadId, ulong playerId, long? upToMessageId = null) { var t = GetThread(threadId); if (t == null) return; long latest = t.Messages.Count > 0 ? t.Messages[^1].ChatMessageId : 0; long target = Math.Min(upToMessageId ?? latest, latest); UpdateState(threadId, playerId, s => { if (target > s.LastReadMessageId) s.LastReadMessageId = target; }); } // ---------------- messages ---------------- public static ChatMessage? GetMessage(long messageId) => Messages.FindOne(x => x.ChatMessageId == messageId); public static ChatMessage? AddMessage(long threadId, int senderId, string jsonContents) { var thread = Threads.FindOne(x => x.ChatThreadId == threadId); if (thread == null) return null; ChatMessage msg; lock (_idLock) { msg = new ChatMessage { ChatMessageId = NextMessageId(), ChatThreadId = threadId, SenderPlayerId = senderId, TimeSent = DateTime.UtcNow, Contents = jsonContents, ModerationState = 0 }; Messages.Insert(msg); } thread.LastActivityAt = msg.TimeSent; // The sender has read their own message. if (senderId > 0) { thread.MemberStates ??= new(); string key = ((ulong)senderId).ToString(); if (!thread.MemberStates.TryGetValue(key, out var s) || s == null) s = new ChatMemberState(); s.LastReadMessageId = msg.ChatMessageId; thread.MemberStates[key] = s; } Threads.Update(thread); return msg; } public static object MessageDto(ChatMessage m) => new { chatMessageId = m.ChatMessageId, chatThreadId = m.ChatThreadId, senderPlayerId = m.SenderPlayerId, timeSent = m.TimeSent.ToUniversalTime(), // LiteDB hands back local time; the client wants a UTC instant contents = m.Contents, moderationState = m.ModerationState }; /// Push a message to every member of its thread (ChatMessageReceived, or another frame id). public static void Push(ChatThread thread, ChatMessage m, string frameId = "ChatMessageReceived") { var payload = JsonConvert.SerializeObject(new { Id = frameId, Msg = MessageDto(m) }); Notifications.FireAndForget(Notifications.SendToPlayers(thread.PlayerIds.ToList(), payload), $"chat push {frameId}"); } /// Post a system (-5) notice into a thread and push it. public static ChatMessage? SystemNotice(long threadId, string text) { var contents = JsonConvert.SerializeObject(new MessageJson { Type = MessageContentType.Text, Version = 1, Data = text }); var m = AddMessage(threadId, SystemSenderId, contents); if (m == null) return null; var t = GetThread(threadId); if (t != null) Push(t, m); return m; } // Kept for older callers. public static ChatThread? RenameThread(long threadId, ulong senderId, string newThreadName) { var t = SetName(threadId, newThreadName); if (t != null) SystemNotice(threadId, $"Player <@U{senderId}> renamed the chat"); return GetThread(threadId); } public static ChatThread? LeaveThread(long threadId, ulong senderId) { // Notice first (the leaver is still a member and gets the push that tells their // client the thread is gone), then drop the membership. SystemNotice(threadId, $"Player <@U{senderId}> left"); return RemoveMember(threadId, senderId); } } }