using System.Text.Json; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace EchoHub.Server.Services; public class ChatService : IChatService { private readonly IServiceScopeFactory _scopeFactory; private readonly PresenceTracker _presenceTracker; private readonly IEnumerable _broadcasters; private readonly LinkEmbedService _embedService; private readonly IMessageEncryptionService _encryption; private readonly IChannelService _channelService; private readonly ILogger _logger; public ChatService( IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, IEnumerable broadcasters, LinkEmbedService embedService, IMessageEncryptionService encryption, IChannelService channelService, ILogger logger) { _scopeFactory = scopeFactory; _presenceTracker = presenceTracker; _broadcasters = broadcasters; _embedService = embedService; _encryption = encryption; _channelService = channelService; _logger = logger; } public async Task UserConnectedAsync(string connectionId, Guid userId, string username) { _presenceTracker.UserConnected(connectionId, userId, username); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FindAsync(userId); if (user is not null) { user.LastSeenAt = DateTimeOffset.UtcNow; user.Status = UserStatus.Online; await db.SaveChangesAsync(); } _logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId); } public async Task UserDisconnectedAsync(string connectionId) { var preDisconnectUsername = _presenceTracker.GetUsernameForConnection(connectionId); var channelsBeforeDisconnect = preDisconnectUsername is not null ? _presenceTracker.GetChannelsForUser(preDisconnectUsername) : []; var username = _presenceTracker.UserDisconnected(connectionId); if (username is not null && !_presenceTracker.IsOnline(username)) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); if (user is not null) { user.LastSeenAt = DateTimeOffset.UtcNow; user.Status = UserStatus.Invisible; await db.SaveChangesAsync(); var presence = new UserPresenceDto( username, user.DisplayName, user.NicknameColor, UserStatus.Invisible, user.StatusMessage, user.Role); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence)); } } _logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId); return username; } public async Task<(List History, string? Error)> JoinChannelAsync( string connectionId, Guid userId, string username, string channelName) { channelName = channelName.ToLowerInvariant().Trim(); // Delegate channel validation + membership to ChannelService var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName); if (!success) return ([], error); var isNewJoin = _presenceTracker.JoinChannel(username, channelName); if (isNewJoin) { // Fetch presence data so clients can update their lists incrementally UserPresenceDto? presence = null; try { using var presenceScope = _scopeFactory.CreateScope(); var presenceDb = presenceScope.ServiceProvider.GetRequiredService(); var user = await presenceDb.Users.FindAsync(userId); if (user is not null) { presence = new UserPresenceDto( user.Username, user.DisplayName, user.NicknameColor, user.Status, user.StatusMessage, user.Role); } } catch (Exception ex) { _logger.LogDebug(ex, "Failed to fetch presence for {User} on join", username); } // Don't broadcast join for invisible users — they still get history but stay hidden if (presence is null || presence.Status != UserStatus.Invisible) { await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId)); } _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); } var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount); return (history, null); } public async Task LeaveChannelAsync(string connectionId, string username, string channelName) { channelName = channelName.ToLowerInvariant().Trim(); _presenceTracker.LeaveChannel(username, channelName); await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username)); _logger.LogInformation("{User} left channel '{Channel}'", username, channelName); } public async Task SendMessageAsync(Guid userId, string username, string channelName, string content) { channelName = channelName.ToLowerInvariant().Trim(); if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return "Invalid channel name."; // Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both) var plaintext = _encryption.Decrypt(content); // Strip encryption prefix if a user typed it literally (prevents spoofing) while (plaintext.StartsWith("$ENC$")) plaintext = plaintext["$ENC$".Length..]; if (string.IsNullOrWhiteSpace(plaintext)) return "Message content cannot be empty."; if (plaintext.Length > HubConstants.MaxMessageLength) return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; // Sanitize on plaintext: collapse excessive newlines plaintext = SanitizeNewlines(plaintext); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) return $"Channel '{channelName}' does not exist."; var sender = await db.Users.FindAsync(userId); // Check mute status if (sender is not null && sender.IsMuted) { if (sender.MutedUntil.HasValue && sender.MutedUntil.Value <= DateTimeOffset.UtcNow) { sender.IsMuted = false; sender.MutedUntil = null; await db.SaveChangesAsync(); } else { return "You are muted and cannot send messages."; } } // Attempt to fetch link embeds for URLs in the plaintext message List? embeds = null; try { embeds = await _embedService.TryGetEmbedsAsync(plaintext); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName); } // Store in DB — encrypted at rest if enabled, plaintext otherwise var embedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null; var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(plaintext) : plaintext; var dbEmbedJson = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(embedJson) : embedJson; var message = new Message { Id = Guid.NewGuid(), Content = dbContent, Type = MessageType.Text, SentAt = DateTimeOffset.UtcNow, ChannelId = channel.Id, SenderUserId = userId, SenderUsername = username, EmbedJson = dbEmbedJson, }; db.Messages.Add(message); await db.SaveChangesAsync(); // Broadcast encrypted for SignalR clients; IRC broadcaster gets plaintext var encryptedContent = _encryption.Encrypt(plaintext); var messageDto = new MessageDto( message.Id, encryptedContent, message.SenderUsername, sender?.NicknameColor, channelName, MessageType.Text, null, null, message.SentAt, Embeds: embeds); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); _logger.LogDebug("{User} sent message in '{Channel}'", username, channelName); return null; } public async Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0) { channelName = channelName.ToLowerInvariant().Trim(); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); offset = Math.Max(offset, 0); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await GetChannelHistoryInternalAsync(db, channelName, count, offset); } public async Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) { if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength) return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters."; using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FindAsync(userId); if (user is null) return "User not found."; user.Status = status; user.StatusMessage = statusMessage?.Trim(); user.LastSeenAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); var presence = new UserPresenceDto( user.Username, user.DisplayName, user.NicknameColor, status, statusMessage, user.Role); var channels = _presenceTracker.GetChannelsForUser(username); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); return null; } public async Task> GetOnlineUsersAsync(string channelName) { channelName = channelName.ToLowerInvariant().Trim(); var onlineUsernames = _presenceTracker.GetOnlineUsersInChannel(channelName); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await db.Users .Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible) .Select(u => new UserPresenceDto( u.Username, u.DisplayName, u.NicknameColor, u.Status, u.StatusMessage, u.Role)) .ToListAsync(); } public Task BroadcastMessageAsync(string channelName, MessageDto message) => BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, message)); public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) => BroadcastToAllAsync(b => b.SendChannelUpdatedAsync(channel, channelName)); private async Task BroadcastToAllAsync(Func action) { foreach (var broadcaster in _broadcasters) { try { await action(broadcaster); } catch (Exception ex) { _logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name); } } } public Task> GetChannelsForUserAsync(string username) => Task.FromResult(_presenceTracker.GetChannelsForUser(username)); /// /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// private static string SanitizeNewlines(string content) { // Normalize \r\n → \n content = content.Replace("\r\n", "\n").Replace('\r', '\n'); // Collapse consecutive blank/whitespace-only lines into max 1 blank line var lines = content.Split('\n'); var result = new List(lines.Length); int consecutiveBlanks = 0; foreach (var line in lines) { if (string.IsNullOrWhiteSpace(line)) { consecutiveBlanks++; if (consecutiveBlanks <= HubConstants.MaxConsecutiveNewlines) result.Add(line); } else { consecutiveBlanks = 0; result.Add(line); } } // Cap total lines if (result.Count > HubConstants.MaxMessageNewlines) result = result.Take(HubConstants.MaxMessageNewlines).ToList(); return string.Join('\n', result); } private async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0) { var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) return []; var raw = await db.Messages .Where(m => m.ChannelId == channel.Id) .OrderByDescending(m => m.SentAt) .Skip(offset) .Take(count) .Join(db.Users, m => m.SenderUserId, u => u.Id, (m, u) => new { m, u.NicknameColor }) .ToListAsync(); raw.Reverse(); return raw.Select(x => { // Decrypt DB content (handles both encrypted and plaintext via prefix detection) var plaintext = _encryption.Decrypt(x.m.Content); var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson); List? embeds = null; if (embedJsonPlain is not null) { try { embeds = JsonSerializer.Deserialize>(embedJsonPlain); } catch { /* ignore malformed JSON */ } } // Encrypt for transport — client decrypts return new MessageDto( x.m.Id, _encryption.Encrypt(plaintext), x.m.SenderUsername, x.NicknameColor, channelName, x.m.Type, x.m.AttachmentUrl, x.m.AttachmentFileName, x.m.SentAt, x.m.AttachmentFileSize, embeds); }).ToList(); } }