feat: Implement IRC Gateway Service and related functionality

- Add IrcGatewayService to handle IRC connections and commands.
- Create IrcMessage class for parsing IRC protocol lines.
- Implement IrcMessageFormatter for formatting messages as IRC lines.
- Define IrcNumericReply constants for IRC numeric replies.
- Add IrcOptions class for configuration settings related to IRC.
- Create IrcServiceExtensions for adding IRC services to the application.
- Refactor ChannelsController to use IChatService for broadcasting messages and channel updates.
- Update ChatHub to utilize IChatService for user connection and message handling.
- Introduce ChatService to manage chat-related operations and interactions.
- Implement SignalRBroadcaster for broadcasting messages to SignalR clients.
- Update PresenceTracker to retrieve usernames for connections.
- Modify appsettings.example.json to include IRC configuration options.
- Update solution file to include the new IRC project.
This commit is contained in:
HueByte
2026-02-19 11:31:20 +01:00
parent da7c16d5d0
commit cb6b9d1e55
21 changed files with 1706 additions and 215 deletions
+17 -208
View File
@@ -3,16 +3,13 @@ using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Hubs;
[Authorize]
public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTracker presenceTracker) : Hub<IEchoHubClient>
public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IEchoHubClient>
{
private Guid CurrentUserId =>
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -26,19 +23,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{
try
{
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
var user = await db.Users.FindAsync(CurrentUserId);
if (user is not null)
{
user.LastSeenAt = DateTimeOffset.UtcNow;
user.Status = UserStatus.Online;
await db.SaveChangesAsync();
}
await chatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername);
await base.OnConnectedAsync();
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
}
catch (Exception ex)
{
@@ -51,39 +37,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{
try
{
var preDisconnectUsername = Context.User?.FindFirstValue("username");
var channelsBeforeDisconnect = preDisconnectUsername is not null
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
: [];
var username = presenceTracker.UserDisconnected(Context.ConnectionId);
if (username is not null && !presenceTracker.IsOnline(username))
{
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);
foreach (var channel in channelsBeforeDisconnect)
{
await Clients.Group(channel).UserStatusChanged(presence);
}
}
}
await chatService.UserDisconnectedAsync(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
}
catch (Exception ex)
{
@@ -96,32 +51,16 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{
try
{
channelName = channelName.ToLowerInvariant().Trim();
var (history, error) = await chatService.JoinChannelAsync(
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
if (error is not null)
{
await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
await Clients.Caller.Error(error);
return [];
}
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list.");
return [];
}
var isNewJoin = presenceTracker.JoinChannel(CurrentUsername, channelName);
if (isNewJoin)
{
await Groups.AddToGroupAsync(Context.ConnectionId, channelName);
await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername);
logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName);
}
var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount);
await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
return history;
}
catch (Exception ex)
@@ -137,13 +76,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
try
{
channelName = channelName.ToLowerInvariant().Trim();
presenceTracker.LeaveChannel(CurrentUsername, channelName);
await chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername);
logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName);
}
catch (Exception ex)
{
@@ -156,64 +90,9 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{
try
{
channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
{
await Clients.Caller.Error("Invalid channel name.");
return;
}
if (string.IsNullOrWhiteSpace(content))
{
await Clients.Caller.Error("Message content cannot be empty.");
return;
}
if (content.Length > HubConstants.MaxMessageLength)
{
await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.");
return;
}
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
await Clients.Caller.Error($"Channel '{channelName}' does not exist.");
return;
}
var sender = await db.Users.FindAsync(CurrentUserId);
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Type = MessageType.Text,
SentAt = DateTimeOffset.UtcNow,
ChannelId = channel.Id,
SenderUserId = CurrentUserId,
SenderUsername = CurrentUsername,
};
db.Messages.Add(message);
await db.SaveChangesAsync();
var messageDto = new MessageDto(
message.Id,
message.Content,
message.SenderUsername,
sender?.NicknameColor,
channelName,
MessageType.Text,
null,
null,
message.SentAt);
await Clients.Group(channelName).ReceiveMessage(messageDto);
logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName);
var error = await chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
if (error is not null)
await Clients.Caller.Error(error);
}
catch (Exception ex)
{
@@ -226,35 +105,7 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{
try
{
channelName = channelName.ToLowerInvariant().Trim();
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
return [];
var messages = await db.Messages
.Where(m => m.ChannelId == channel.Id)
.OrderByDescending(m => m.SentAt)
.Take(count)
.Join(db.Users,
m => m.SenderUserId,
u => u.Id,
(m, u) => new MessageDto(
m.Id,
m.Content,
m.SenderUsername,
u.NicknameColor,
channelName,
m.Type,
m.AttachmentUrl,
m.AttachmentFileName,
m.SentAt))
.ToListAsync();
messages.Reverse();
return messages;
return await chatService.GetChannelHistoryAsync(channelName, count);
}
catch (Exception ex)
{
@@ -268,37 +119,9 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{
try
{
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
{
await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.");
return;
}
var user = await db.Users.FindAsync(CurrentUserId);
if (user is null)
{
await Clients.Caller.Error("User not found.");
return;
}
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);
var channels = presenceTracker.GetChannelsForUser(CurrentUsername);
var connections = presenceTracker.GetConnectionsInChannels(channels);
if (connections.Count > 0)
await Clients.Clients(connections).UserStatusChanged(presence);
var error = await chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage);
if (error is not null)
await Clients.Caller.Error(error);
}
catch (Exception ex)
{
@@ -311,21 +134,7 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{
try
{
channelName = channelName.ToLowerInvariant().Trim();
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
var users = await db.Users
.Where(u => onlineUsernames.Contains(u.Username))
.Select(u => new UserPresenceDto(
u.Username,
u.DisplayName,
u.NicknameColor,
u.Status,
u.StatusMessage))
.ToListAsync();
return users;
return await chatService.GetOnlineUsersAsync(channelName);
}
catch (Exception ex)
{