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
@@ -0,0 +1,13 @@
using EchoHub.Core.DTOs;
namespace EchoHub.Core.Contracts;
public interface IChatBroadcaster
{
Task SendMessageToChannelAsync(string channelName, MessageDto message);
Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null);
Task SendUserLeftAsync(string channelName, string username);
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
Task SendErrorAsync(string connectionId, string message);
}
@@ -0,0 +1,36 @@
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
namespace EchoHub.Core.Contracts;
public interface IChatService
{
// Connection lifecycle
Task UserConnectedAsync(string connectionId, Guid userId, string username);
Task<string?> UserDisconnectedAsync(string connectionId);
// Channel operations
Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName);
Task LeaveChannelAsync(string connectionId, string username, string channelName);
// Messaging
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count);
// Presence
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName);
// Broadcasting (used by controllers and IRC gateway)
Task BroadcastMessageAsync(string channelName, MessageDto message);
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
// Query operations (used by IRC gateway for WHOIS, TOPIC, LIST, AUTH)
Task<UserProfileDto?> GetUserProfileAsync(string username);
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<List<string>> GetChannelsForUserAsync(string username);
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
}
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+67
View File
@@ -0,0 +1,67 @@
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
namespace EchoHub.Server.Irc;
public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
{
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
{
var lines = IrcMessageFormatter.FormatMessage(message);
foreach (var conn in gateway.GetConnectionsInChannel(channelName))
{
// IRC convention: don't echo sender's own message
if (conn.Nickname == message.SenderUsername)
continue;
foreach (var line in lines)
await conn.SendAsync(line);
}
}
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
{
foreach (var conn in gateway.GetConnectionsInChannel(channelName))
{
if (conn.ConnectionId == excludeConnectionId) continue;
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
}
}
public async Task SendUserLeftAsync(string channelName, string username)
{
foreach (var conn in gateway.GetConnectionsInChannel(channelName))
{
if (conn.Nickname == username) continue;
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
}
}
public async Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
{
var target = channelName ?? channel.Name;
if (channel.Topic is null) return;
foreach (var conn in gateway.GetConnectionsInChannel(target))
{
await conn.SendAsync($":{gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
}
}
public Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence)
{
// IRC has no active status broadcast. Clients discover away via WHOIS/WHO.
return Task.CompletedTask;
}
public async Task SendErrorAsync(string connectionId, string message)
{
if (!connectionId.StartsWith("irc-")) return;
if (gateway.Connections.TryGetValue(connectionId, out var conn))
{
await conn.SendAsync($":{gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}");
}
}
}
@@ -0,0 +1,87 @@
using System.Net.Sockets;
using System.Text;
namespace EchoHub.Server.Irc;
/// <summary>
/// Manages a single IRC client TCP connection.
/// </summary>
public sealed class IrcClientConnection : IAsyncDisposable
{
private readonly TcpClient _tcpClient;
private readonly StreamReader _reader;
private readonly StreamWriter _writer;
private readonly SemaphoreSlim _writeLock = new(1, 1);
// Connection identity
public string ConnectionId { get; } = $"irc-{Guid.NewGuid()}";
// Registration state
public string? Nickname { get; set; }
public string? Username { get; set; }
public string? RealName { get; set; }
public string? Password { get; set; }
public Guid? UserId { get; set; }
public bool IsRegistered { get; set; }
public bool IsAuthenticated { get; set; }
public bool IsSasl { get; set; }
public bool CapNegotiating { get; set; }
// Channel state
public HashSet<string> JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase);
// Away state
public string? AwayMessage { get; set; }
public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub";
public IrcClientConnection(TcpClient tcpClient, Stream stream)
{
_tcpClient = tcpClient;
_reader = new StreamReader(stream, Encoding.UTF8);
_writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true, NewLine = "\r\n" };
}
public async Task<string?> ReadLineAsync(CancellationToken ct)
{
try
{
return await _reader.ReadLineAsync(ct);
}
catch
{
return null;
}
}
public async Task SendAsync(string line)
{
await _writeLock.WaitAsync();
try
{
await _writer.WriteLineAsync(line);
}
catch
{
// Connection lost — swallow
}
finally
{
_writeLock.Release();
}
}
public Task SendNumericAsync(string serverName, string numeric, string target, string text)
=> SendAsync($":{serverName} {numeric} {target} {text}");
public Task SendNumericAsync(string serverName, string numeric, string text)
=> SendNumericAsync(serverName, numeric, Nickname ?? "*", text);
public async ValueTask DisposeAsync()
{
try { _tcpClient.Close(); } catch { }
_reader.Dispose();
_writer.Dispose();
_writeLock.Dispose();
}
}
+641
View File
@@ -0,0 +1,641 @@
using System.Text;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Microsoft.Extensions.Logging;
namespace EchoHub.Server.Irc;
public sealed class IrcCommandHandler
{
private readonly IrcClientConnection _conn;
private readonly IrcOptions _options;
private readonly IChatService _chatService;
private readonly ILogger _logger;
private string ServerName => _options.ServerName;
public IrcCommandHandler(
IrcClientConnection conn,
IrcOptions options,
IChatService chatService,
ILogger logger)
{
_conn = conn;
_options = options;
_chatService = chatService;
_logger = logger;
}
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var line = await _conn.ReadLineAsync(ct);
if (line is null) break;
line = line.TrimEnd('\r', '\n');
if (string.IsNullOrWhiteSpace(line)) continue;
var msg = IrcMessage.Parse(line);
try
{
await HandleCommandAsync(msg);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling IRC command {Command} for {Nick}",
msg.Command, _conn.Nickname ?? "unregistered");
}
}
}
private Task HandleCommandAsync(IrcMessage msg)
{
var command = msg.Command.ToUpperInvariant();
return command switch
{
// Pre-registration
"CAP" => HandleCapAsync(msg),
"AUTHENTICATE" => HandleAuthenticateAsync(msg),
"PASS" => HandlePassAsync(msg),
"NICK" => HandleNickAsync(msg),
"USER" => HandleUserAsync(msg),
// Post-registration
"PING" => HandlePingAsync(msg),
"PONG" => Task.CompletedTask,
"JOIN" => HandleJoinAsync(msg),
"PART" => HandlePartAsync(msg),
"PRIVMSG" => HandlePrivmsgAsync(msg),
"QUIT" => HandleQuitAsync(msg),
"NAMES" => HandleNamesAsync(msg),
"TOPIC" => HandleTopicAsync(msg),
"WHO" => HandleWhoAsync(msg),
"WHOIS" => HandleWhoisAsync(msg),
"AWAY" => HandleAwayAsync(msg),
"LIST" => HandleListAsync(msg),
"MODE" => HandleModeAsync(msg),
"MOTD" => SendMotdAsync(),
"USERHOST" or "LUSERS" => Task.CompletedTask,
_ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND,
$"{command} :Unknown command"),
};
}
// ── Authentication ──────────────────────────────────────────────────────
private async Task HandleCapAsync(IrcMessage msg)
{
if (msg.Parameters.Count < 1) return;
switch (msg.Parameters[0].ToUpperInvariant())
{
case "LS":
await _conn.SendAsync($":{ServerName} CAP * LS :sasl");
_conn.CapNegotiating = true;
break;
case "REQ":
if (msg.Parameters.Count >= 2 &&
msg.Parameters[1].Trim().Equals("sasl", StringComparison.OrdinalIgnoreCase))
{
await _conn.SendAsync($":{ServerName} CAP * ACK :sasl");
_conn.IsSasl = true;
}
else
{
var requested = msg.Parameters.ElementAtOrDefault(1) ?? "";
await _conn.SendAsync($":{ServerName} CAP * NAK :{requested}");
}
break;
case "END":
_conn.CapNegotiating = false;
if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered)
await TryCompleteRegistrationAsync();
break;
}
}
private async Task HandleAuthenticateAsync(IrcMessage msg)
{
if (msg.Parameters.Count < 1) return;
if (msg.Parameters[0].Equals("PLAIN", StringComparison.OrdinalIgnoreCase))
{
await _conn.SendAsync("AUTHENTICATE +");
return;
}
try
{
var decoded = Convert.FromBase64String(msg.Parameters[0]);
var text = Encoding.UTF8.GetString(decoded);
var parts = text.Split('\0');
if (parts.Length < 3)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
":SASL authentication failed (malformed payload)");
return;
}
var username = (parts[1].Length > 0 ? parts[1] : parts[0]).ToLowerInvariant();
var password = parts[2];
var result = await _chatService.AuthenticateUserAsync(username, password);
if (result is null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
":SASL authentication failed");
return;
}
_conn.Nickname = result.Value.Username;
_conn.UserId = result.Value.UserId;
_conn.IsAuthenticated = true;
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LOGGEDIN,
$"{_conn.Hostmask} {username} :You are now logged in as {username}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_SASLSUCCESS,
":SASL authentication successful");
}
catch
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
":SASL authentication failed");
}
}
private Task HandlePassAsync(IrcMessage msg)
{
if (_conn.IsRegistered)
return _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ALREADYREGISTERED,
":You may not reregister");
if (msg.Parameters.Count >= 1)
_conn.Password = msg.Parameters[0];
return Task.CompletedTask;
}
private async Task HandleNickAsync(IrcMessage msg)
{
if (msg.Parameters.Count < 1)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NONICKNAMEGIVEN,
":No nickname given");
return;
}
var nick = msg.Parameters[0];
if (!ValidationConstants.UsernameRegex().IsMatch(nick))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ERRONEUSNICKNAME,
$"{nick} :Erroneous nickname (must be 3-50 chars: a-z, 0-9, _, -)");
return;
}
_conn.Nickname = nick.ToLowerInvariant();
if (!_conn.IsRegistered && _conn.Username is not null)
await TryCompleteRegistrationAsync();
}
private async Task HandleUserAsync(IrcMessage msg)
{
if (_conn.IsRegistered)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ALREADYREGISTERED,
":You may not reregister");
return;
}
if (msg.Parameters.Count < 4)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
"USER :Not enough parameters");
return;
}
_conn.Username = msg.Parameters[0];
_conn.RealName = msg.Parameters[3];
if (_conn.Nickname is not null)
await TryCompleteRegistrationAsync();
}
private async Task TryCompleteRegistrationAsync()
{
if (_conn.CapNegotiating || _conn.IsRegistered) return;
// SASL already authenticated
if (_conn.IsAuthenticated && _conn.UserId is not null)
{
_conn.IsRegistered = true;
await _chatService.UserConnectedAsync(_conn.ConnectionId, _conn.UserId.Value, _conn.Nickname!);
await SendWelcomeBurstAsync();
return;
}
// PASS-based authentication
if (string.IsNullOrEmpty(_conn.Password))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
":Password required. Use PASS command or SASL PLAIN.");
await _conn.SendAsync("ERROR :Authentication failed - no password provided");
return;
}
var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password);
if (result is null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
":Password incorrect or account not found. Register via the EchoHub client first.");
await _conn.SendAsync("ERROR :Authentication failed");
return;
}
_conn.UserId = result.Value.UserId;
_conn.Nickname = result.Value.Username;
_conn.IsAuthenticated = true;
_conn.IsRegistered = true;
await _chatService.UserConnectedAsync(_conn.ConnectionId, result.Value.UserId, result.Value.Username);
await SendWelcomeBurstAsync();
}
// ── Welcome / MOTD ──────────────────────────────────────────────────────
private async Task SendWelcomeBurstAsync()
{
var nick = _conn.Nickname!;
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WELCOME,
$":Welcome to the EchoHub IRC Gateway, {nick}!");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_YOURHOST,
$":Your host is {ServerName}, running EchoHub IRC Gateway");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CREATED,
$":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
$"{ServerName} EchoHub-IRC o o");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
"CHANTYPES=# NICKLEN=50 CHANNELLEN=100 :are supported by this server");
await SendMotdAsync();
}
private async Task SendMotdAsync()
{
if (string.IsNullOrWhiteSpace(_options.Motd))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOMOTD,
":MOTD File is missing");
return;
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MOTDSTART,
$":- {ServerName} Message of the day - ");
foreach (var line in _options.Motd.Split('\n'))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MOTD,
$":- {line.TrimEnd('\r')}");
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFMOTD,
":End of MOTD command");
}
// ── Channel Operations ──────────────────────────────────────────────────
private async Task HandleJoinAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
if (msg.Parameters.Count < 1)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
"JOIN :Not enough parameters");
return;
}
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
foreach (var rawChannel in channels)
{
var channelName = IrcToEchoHubChannel(rawChannel);
if (channelName is null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
$"{rawChannel} :Invalid channel name");
continue;
}
var (history, error) = await _chatService.JoinChannelAsync(
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName);
if (error is not null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
$"#{channelName} :{error}");
continue;
}
_conn.JoinedChannels.Add(channelName);
// Confirm JOIN to the client
await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}");
// Send topic
await SendChannelTopicAsync(channelName);
// Send NAMES list
await SendNamesReplyAsync(channelName);
// Replay history
foreach (var m in history)
{
var lines = IrcMessageFormatter.FormatMessage(m);
foreach (var line in lines)
await _conn.SendAsync(line);
}
}
}
private async Task HandlePartAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
if (msg.Parameters.Count < 1) return;
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
var partMessage = msg.Parameters.Count > 1 ? msg.Parameters[1] : null;
foreach (var rawChannel in channels)
{
var channelName = IrcToEchoHubChannel(rawChannel);
if (channelName is null) continue;
await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName);
_conn.JoinedChannels.Remove(channelName);
await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
(partMessage is not null ? $" :{partMessage}" : ""));
}
}
private async Task HandlePrivmsgAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
if (msg.Parameters.Count < 2)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
"PRIVMSG :Not enough parameters");
return;
}
var target = msg.Parameters[0];
var content = msg.Parameters[1];
if (!target.StartsWith('#'))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHNICK,
$"{target} :Private messages are not supported. Use channels.");
return;
}
var channelName = IrcToEchoHubChannel(target);
if (channelName is null) return;
var error = await _chatService.SendMessageAsync(
_conn.UserId!.Value, _conn.Nickname!, channelName, content);
if (error is not null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CANNOTSENDTOCHAN,
$"#{channelName} :{error}");
}
}
private async Task HandleQuitAsync(IrcMessage msg)
{
var quitMessage = msg.Parameters.Count > 0 ? msg.Parameters[0] : "Client quit";
await _conn.SendAsync($"ERROR :Closing Link: {_conn.Nickname} ({quitMessage})");
}
// ── Query Commands ──────────────────────────────────────────────────────
private async Task HandleNamesAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
if (channelName is null) return;
await SendNamesReplyAsync(channelName);
}
private async Task SendNamesReplyAsync(string channelName)
{
var users = await _chatService.GetOnlineUsersAsync(channelName);
var nicks = string.Join(" ", users.Select(u => u.Username));
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NAMREPLY,
$"= #{channelName} :{nicks}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFNAMES,
$"#{channelName} :End of /NAMES list");
}
private async Task HandleTopicAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
if (channelName is null) return;
if (msg.Parameters.Count == 1)
{
await SendChannelTopicAsync(channelName);
}
else
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CHANOPRIVSNEEDED,
$"#{channelName} :Topic can only be changed by the channel creator via the API");
}
}
private async Task SendChannelTopicAsync(string channelName)
{
var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName);
if (!exists) return;
if (topic is not null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_TOPIC,
$"#{channelName} :{topic}");
}
else
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NOTOPIC,
$"#{channelName} :No topic is set");
}
}
private async Task HandleWhoAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
if (channelName is null) return;
var users = await _chatService.GetOnlineUsersAsync(channelName);
foreach (var u in users)
{
var awayFlag = u.Status == UserStatus.Away ? "G" : "H";
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOREPLY,
$"#{channelName} {u.Username} echohub {ServerName} {u.Username} {awayFlag} :0 {u.DisplayName ?? u.Username}");
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFWHO,
$"#{channelName} :End of WHO list");
}
private async Task HandleWhoisAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
if (msg.Parameters.Count < 1) return;
var nick = msg.Parameters[^1].ToLowerInvariant();
var profile = await _chatService.GetUserProfileAsync(nick);
if (profile is null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHNICK,
$"{nick} :No such nick/channel");
return;
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISUSER,
$"{nick} {nick} echohub * :{profile.DisplayName ?? nick}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISSERVER,
$"{nick} {ServerName} :EchoHub IRC Gateway");
var channels = await _chatService.GetChannelsForUserAsync(nick);
if (channels.Count > 0)
{
var chanList = string.Join(" ", channels.Select(c => $"#{c}"));
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISCHANNELS,
$"{nick} :{chanList}");
}
if (profile.Status == UserStatus.Away && profile.StatusMessage is not null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_AWAY,
$"{nick} :{profile.StatusMessage}");
}
var idleSeconds = (long)(DateTimeOffset.UtcNow - profile.LastSeenAt).TotalSeconds;
var signonUnix = profile.CreatedAt.ToUnixTimeSeconds();
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISIDLE,
$"{nick} {idleSeconds} {signonUnix} :seconds idle, signon time");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFWHOIS,
$"{nick} :End of WHOIS list");
}
private async Task HandleAwayAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0]))
{
_conn.AwayMessage = msg.Parameters[0];
await _chatService.UpdateStatusAsync(
_conn.UserId!.Value, _conn.Nickname!, UserStatus.Away, _conn.AwayMessage);
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NOWAWAY,
":You have been marked as being away");
}
else
{
_conn.AwayMessage = null;
await _chatService.UpdateStatusAsync(
_conn.UserId!.Value, _conn.Nickname!, UserStatus.Online, null);
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UNAWAY,
":You are no longer marked as being away");
}
}
private async Task HandleListAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
var channels = await _chatService.GetChannelListAsync();
foreach (var ch in channels)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LIST,
$"#{ch.Name} {ch.OnlineCount} :{ch.Topic ?? ""}");
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LISTEND,
":End of LIST");
}
private async Task HandleModeAsync(IrcMessage msg)
{
if (!RequireRegistered()) return;
if (msg.Parameters.Count < 1) return;
var target = msg.Parameters[0];
if (target.StartsWith('#'))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS,
$"{target} +");
}
else
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UMODEIS, "+");
}
}
private async Task HandlePingAsync(IrcMessage msg)
{
var token = msg.Parameters.Count > 0 ? msg.Parameters[0] : ServerName;
await _conn.SendAsync($":{ServerName} PONG {ServerName} :{token}");
}
// ── Helpers ──────────────────────────────────────────────────────────────
private bool RequireRegistered()
{
if (_conn.IsRegistered) return true;
_ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
":You have not registered");
return false;
}
private static string? IrcToEchoHubChannel(string ircChannel)
{
if (!ircChannel.StartsWith('#') || ircChannel.Length < 2)
return null;
var name = ircChannel[1..].ToLowerInvariant().Trim();
return ValidationConstants.ChannelNameRegex().IsMatch(name) ? name : null;
}
}
+157
View File
@@ -0,0 +1,157 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using EchoHub.Core.Contracts;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace EchoHub.Server.Irc;
public sealed class IrcGatewayService : BackgroundService
{
private readonly IrcOptions _options;
private readonly IChatService _chatService;
private readonly ILogger<IrcGatewayService> _logger;
private readonly ConcurrentDictionary<string, IrcClientConnection> _connections = new();
public IrcOptions Options => _options;
public IReadOnlyDictionary<string, IrcClientConnection> Connections => _connections;
public IrcGatewayService(
IOptions<IrcOptions> options,
IChatService chatService,
ILogger<IrcGatewayService> logger)
{
_options = options.Value;
_chatService = chatService;
_logger = logger;
}
public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName)
{
return _connections.Values
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
if (!_options.Enabled)
{
_logger.LogInformation("IRC gateway is disabled");
return;
}
var listeners = new List<Task>();
listeners.Add(RunListenerAsync(_options.Port, useTls: false, stoppingToken));
if (_options.TlsEnabled && !string.IsNullOrWhiteSpace(_options.TlsCertPath))
{
listeners.Add(RunListenerAsync(_options.TlsPort, useTls: true, stoppingToken));
}
await Task.WhenAll(listeners);
}
private async Task RunListenerAsync(int port, bool useTls, CancellationToken ct)
{
var listener = new TcpListener(IPAddress.Any, port);
listener.Start();
_logger.LogInformation("IRC gateway listening on port {Port} ({Mode})",
port, useTls ? "TLS" : "plain");
ct.Register(() => listener.Stop());
try
{
while (!ct.IsCancellationRequested)
{
var tcpClient = await listener.AcceptTcpClientAsync(ct);
_ = HandleClientAsync(tcpClient, useTls, ct);
}
}
catch (OperationCanceledException) { }
catch (ObjectDisposedException) { }
finally
{
listener.Stop();
}
}
private async Task HandleClientAsync(TcpClient tcpClient, bool useTls, CancellationToken ct)
{
Stream stream = tcpClient.GetStream();
if (useTls)
{
try
{
var cert = X509CertificateLoader.LoadPkcs12FromFile(_options.TlsCertPath!, _options.TlsCertPassword);
var sslStream = new SslStream(stream, leaveInnerStreamOpen: false);
await sslStream.AuthenticateAsServerAsync(cert);
stream = sslStream;
}
catch (Exception ex)
{
_logger.LogError(ex, "TLS handshake failed");
tcpClient.Close();
return;
}
}
var connection = new IrcClientConnection(tcpClient, stream);
_connections[connection.ConnectionId] = connection;
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
try
{
var handler = new IrcCommandHandler(
connection, _options, _chatService, _logger);
await handler.RunAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "IRC client {Id} error", connection.ConnectionId);
}
finally
{
if (connection.IsAuthenticated)
{
foreach (var ch in connection.JoinedChannels.ToList())
{
await _chatService.LeaveChannelAsync(
connection.ConnectionId, connection.Nickname!, ch);
}
await _chatService.UserDisconnectedAsync(connection.ConnectionId);
}
_connections.TryRemove(connection.ConnectionId, out _);
await connection.DisposeAsync();
_logger.LogInformation("IRC client {Id} ({Nick}) disconnected",
connection.ConnectionId, connection.Nickname ?? "unregistered");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
foreach (var (_, conn) in _connections)
{
try
{
await conn.SendAsync("ERROR :Server shutting down");
await conn.DisposeAsync();
}
catch { }
}
_connections.Clear();
await base.StopAsync(cancellationToken);
}
}
+69
View File
@@ -0,0 +1,69 @@
namespace EchoHub.Server.Irc;
/// <summary>
/// Parsed representation of an IRC protocol line.
/// Format: [:prefix] COMMAND [params...] [:trailing]
/// </summary>
public sealed class IrcMessage
{
public string? Prefix { get; init; }
public string Command { get; init; } = "";
public List<string> Parameters { get; init; } = [];
public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
/// <summary>
/// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF
/// </summary>
public static IrcMessage Parse(string line)
{
var span = line.AsSpan().TrimEnd("\r\n");
string? prefix = null;
var pos = 0;
// Parse optional prefix
if (span.Length > 0 && span[0] == ':')
{
var spaceIdx = span.IndexOf(' ');
if (spaceIdx == -1)
return new IrcMessage { Prefix = span[1..].ToString() };
prefix = span[1..spaceIdx].ToString();
pos = spaceIdx + 1;
}
// Skip whitespace
while (pos < span.Length && span[pos] == ' ') pos++;
// Parse command
var cmdStart = pos;
while (pos < span.Length && span[pos] != ' ') pos++;
var command = span[cmdStart..pos].ToString();
// Parse parameters
var parameters = new List<string>();
while (pos < span.Length)
{
while (pos < span.Length && span[pos] == ' ') pos++;
if (pos >= span.Length) break;
if (span[pos] == ':')
{
// Trailing parameter (rest of line)
parameters.Add(span[(pos + 1)..].ToString());
break;
}
var paramStart = pos;
while (pos < span.Length && span[pos] != ' ') pos++;
parameters.Add(span[paramStart..pos].ToString());
}
return new IrcMessage
{
Prefix = prefix,
Command = command,
Parameters = parameters,
};
}
}
@@ -0,0 +1,80 @@
using System.Text;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
namespace EchoHub.Server.Irc;
public static class IrcMessageFormatter
{
private const int MaxIrcLineContentBytes = 400;
/// <summary>
/// Format a MessageDto as one or more IRC PRIVMSG lines.
/// </summary>
public static List<string> FormatMessage(MessageDto message)
{
var lines = new List<string>();
var ircChannel = $"#{message.ChannelName}";
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
switch (message.Type)
{
case MessageType.Text:
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
break;
case MessageType.Image:
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {message.AttachmentFileName}]");
if (message.AttachmentUrl is not null)
lines.Add($"{prefix} PRIVMSG {ircChannel} :Download: {message.AttachmentUrl}");
foreach (var line in message.Content.Split('\n'))
{
var trimmed = line.TrimEnd('\r');
if (trimmed.Length > 0)
lines.Add($"{prefix} PRIVMSG {ircChannel} :{trimmed}");
}
break;
case MessageType.File:
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
break;
}
return lines;
}
/// <summary>
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
/// </summary>
public static List<string> SplitMessage(string content, int maxBytes)
{
if (Encoding.UTF8.GetByteCount(content) <= maxBytes)
return [content];
var chunks = new List<string>();
var current = new StringBuilder();
var currentBytes = 0;
foreach (var word in content.Split(' '))
{
var wordBytes = Encoding.UTF8.GetByteCount(word) + 1; // +1 for space
if (currentBytes + wordBytes > maxBytes && current.Length > 0)
{
chunks.Add(current.ToString().TrimEnd());
current.Clear();
currentBytes = 0;
}
current.Append(word).Append(' ');
currentBytes += wordBytes;
}
if (current.Length > 0)
chunks.Add(current.ToString().TrimEnd());
return chunks;
}
}
+65
View File
@@ -0,0 +1,65 @@
namespace EchoHub.Server.Irc;
public static class IrcNumericReply
{
// Connection registration
public const string RPL_WELCOME = "001";
public const string RPL_YOURHOST = "002";
public const string RPL_CREATED = "003";
public const string RPL_MYINFO = "004";
public const string RPL_ISUPPORT = "005";
// MOTD
public const string RPL_MOTDSTART = "375";
public const string RPL_MOTD = "372";
public const string RPL_ENDOFMOTD = "376";
public const string ERR_NOMOTD = "422";
// Channel operations
public const string RPL_NOTOPIC = "331";
public const string RPL_TOPIC = "332";
public const string RPL_NAMREPLY = "353";
public const string RPL_ENDOFNAMES = "366";
// LIST
public const string RPL_LIST = "322";
public const string RPL_LISTEND = "323";
// WHO / WHOIS
public const string RPL_WHOREPLY = "352";
public const string RPL_ENDOFWHO = "315";
public const string RPL_WHOISUSER = "311";
public const string RPL_WHOISSERVER = "312";
public const string RPL_WHOISIDLE = "317";
public const string RPL_ENDOFWHOIS = "318";
public const string RPL_WHOISCHANNELS = "319";
// AWAY
public const string RPL_UNAWAY = "305";
public const string RPL_NOWAWAY = "306";
public const string RPL_AWAY = "301";
// MODE
public const string RPL_CHANNELMODEIS = "324";
public const string RPL_UMODEIS = "221";
// Errors
public const string ERR_NOSUCHNICK = "401";
public const string ERR_NOSUCHCHANNEL = "403";
public const string ERR_CANNOTSENDTOCHAN = "404";
public const string ERR_UNKNOWNCOMMAND = "421";
public const string ERR_NONICKNAMEGIVEN = "431";
public const string ERR_ERRONEUSNICKNAME = "432";
public const string ERR_NICKNAMEINUSE = "433";
public const string ERR_NOTONCHANNEL = "442";
public const string ERR_NOTREGISTERED = "451";
public const string ERR_NEEDMOREPARAMS = "461";
public const string ERR_ALREADYREGISTERED = "462";
public const string ERR_PASSWDMISMATCH = "464";
public const string ERR_CHANOPRIVSNEEDED = "482";
// SASL
public const string RPL_LOGGEDIN = "900";
public const string RPL_SASLSUCCESS = "903";
public const string ERR_SASLFAIL = "904";
}
+15
View File
@@ -0,0 +1,15 @@
namespace EchoHub.Server.Irc;
public sealed class IrcOptions
{
public const string SectionName = "Irc";
public bool Enabled { get; set; }
public int Port { get; set; } = 6667;
public bool TlsEnabled { get; set; }
public int TlsPort { get; set; } = 6697;
public string? TlsCertPath { get; set; }
public string? TlsCertPassword { get; set; }
public string ServerName { get; set; } = "echohub";
public string? Motd { get; set; }
}
@@ -0,0 +1,26 @@
using EchoHub.Core.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace EchoHub.Server.Irc;
public static class IrcServiceExtensions
{
public static WebApplicationBuilder AddIrcGateway(this WebApplicationBuilder builder)
{
builder.Services.Configure<IrcOptions>(
builder.Configuration.GetSection(IrcOptions.SectionName));
if (builder.Configuration.GetValue<bool>("Irc:Enabled"))
{
builder.Services.AddSingleton<IrcGatewayService>();
builder.Services.AddSingleton<IChatBroadcaster>(sp =>
new IrcBroadcaster(sp.GetRequiredService<IrcGatewayService>()));
builder.Services.AddHostedService(sp =>
sp.GetRequiredService<IrcGatewayService>());
}
return builder;
}
}
@@ -4,12 +4,10 @@ using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Hubs;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
@@ -23,7 +21,7 @@ public class ChannelsController(
FileStorageService fileStorage, FileStorageService fileStorage,
ImageToAsciiService asciiService, ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase IChatService chatService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
@@ -78,7 +76,7 @@ public class ChannelsController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
await hubContext.Clients.All.ChannelUpdated(dto); await chatService.BroadcastChannelUpdatedAsync(dto);
return Created($"/api/channels/{channelName}", dto); return Created($"/api/channels/{channelName}", dto);
} }
@@ -107,7 +105,7 @@ public class ChannelsController(
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt); var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
await hubContext.Clients.Group(channelName).ChannelUpdated(dto); await chatService.BroadcastChannelUpdatedAsync(dto, channelName);
return Ok(dto); return Ok(dto);
} }
@@ -214,7 +212,7 @@ public class ChannelsController(
file.FileName, file.FileName,
message.SentAt); message.SentAt);
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); await chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(messageDto); return Ok(messageDto);
} }
@@ -331,7 +329,7 @@ public class ChannelsController(
fileName, fileName,
message.SentAt); message.SentAt);
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); await chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(messageDto); return Ok(messageDto);
} }
+1
View File
@@ -2,6 +2,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" /> <ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+17 -208
View File
@@ -3,16 +3,13 @@ using EchoHub.Core.Constants;
using EchoHub.Core.Contracts; using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Hubs; namespace EchoHub.Server.Hubs;
[Authorize] [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 => private Guid CurrentUserId =>
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier) Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -26,19 +23,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{ {
try try
{ {
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername); await chatService.UserConnectedAsync(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 base.OnConnectedAsync(); await base.OnConnectedAsync();
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -51,39 +37,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{ {
try try
{ {
var preDisconnectUsername = Context.User?.FindFirstValue("username"); await chatService.UserDisconnectedAsync(Context.ConnectionId);
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 base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -96,32 +51,16 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{ {
try 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 []; return [];
} }
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
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);
return history; return history;
} }
catch (Exception ex) catch (Exception ex)
@@ -137,13 +76,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
try try
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
await chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName);
presenceTracker.LeaveChannel(CurrentUsername, channelName);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, 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) catch (Exception ex)
{ {
@@ -156,64 +90,9 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{ {
try try
{ {
channelName = channelName.ToLowerInvariant().Trim(); var error = await chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
if (error is not null)
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) await Clients.Caller.Error(error);
{
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);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -226,35 +105,7 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{ {
try try
{ {
channelName = channelName.ToLowerInvariant().Trim(); return await chatService.GetChannelHistoryAsync(channelName, count);
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;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -268,37 +119,9 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{ {
try try
{ {
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength) var error = await chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage);
{ if (error is not null)
await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters."); await Clients.Caller.Error(error);
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);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -311,21 +134,7 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
{ {
try try
{ {
channelName = channelName.ToLowerInvariant().Trim(); return await chatService.GetOnlineUsersAsync(channelName);
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;
} }
catch (Exception ex) catch (Exception ex)
{ {
+10
View File
@@ -1,10 +1,12 @@
using System.Text; using System.Text;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Auth; using EchoHub.Server.Auth;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Hubs; using EchoHub.Server.Hubs;
using EchoHub.Server.Irc;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using EchoHub.Server.Setup; using EchoHub.Server.Setup;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -101,6 +103,14 @@ while (true)
builder.Services.AddSingleton<ImageToAsciiService>(); builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>(); builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddHostedService<ServerDirectoryService>(); builder.Services.AddHostedService<ServerDirectoryService>();
// ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IChatService, ChatService>();
// ── IRC Gateway (optional) ──────────────────────────────────────────
builder.AddIrcGateway();
builder.Services.AddHttpClient("ImageDownload", client => builder.Services.AddHttpClient("ImageDownload", client =>
{ {
client.Timeout = TimeSpan.FromSeconds(15); client.Timeout = TimeSpan.FromSeconds(15);
+330
View File
@@ -0,0 +1,330 @@
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(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters,
ILogger<ChatService> logger) : IChatService
{
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<EchoHubDbContext>();
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<string?> 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<EchoHubDbContext>();
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);
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
}
}
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
return username;
}
public async Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(
string connectionId, Guid userId, string username, string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
var isNewJoin = presenceTracker.JoinChannel(username, channelName);
if (isNewJoin)
{
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
}
var history = await GetChannelHistoryInternalAsync(db, 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<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
{
channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return "Invalid channel name.";
if (string.IsNullOrWhiteSpace(content))
return "Message content cannot be empty.";
if (content.Length > HubConstants.MaxMessageLength)
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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);
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Type = MessageType.Text,
SentAt = DateTimeOffset.UtcNow,
ChannelId = channel.Id,
SenderUserId = userId,
SenderUsername = username,
};
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 BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
return null;
}
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count)
{
channelName = channelName.ToLowerInvariant().Trim();
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await GetChannelHistoryInternalAsync(db, channelName, count);
}
public async Task<string?> 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<EchoHubDbContext>();
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);
var channels = presenceTracker.GetChannelsForUser(username);
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
return null;
}
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await db.Users
.Where(u => onlineUsernames.Contains(u.Username))
.Select(u => new UserPresenceDto(
u.Username,
u.DisplayName,
u.NicknameColor,
u.Status,
u.StatusMessage))
.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<IChatBroadcaster, Task> action)
{
foreach (var broadcaster in broadcasters)
{
try
{
await action(broadcaster);
}
catch (Exception ex)
{
logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name);
}
}
}
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
username = username.ToLowerInvariant();
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is null) return null;
return new UserProfileDto(
user.Id, user.Username, user.DisplayName, user.Bio,
user.NicknameColor, user.AvatarAscii, user.Status,
user.StatusMessage, user.CreatedAt, user.LastSeenAt);
}
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
return channels.Select(c => new ChannelListItem(
c.Name,
c.Topic,
presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
}
public Task<List<string>> GetChannelsForUserAsync(string username)
=> Task.FromResult(presenceTracker.GetChannelsForUser(username));
public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
{
username = username.ToLowerInvariant();
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is null) return null;
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
return null;
return (user.Id, user.Username);
}
private static async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
{
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;
}
}
@@ -137,6 +137,11 @@ public class PresenceTracker
} }
} }
public string? GetUsernameForConnection(string connectionId)
{
return _connections.TryGetValue(connectionId, out var info) ? info.username : null;
}
public bool IsOnline(string username) public bool IsOnline(string username)
{ {
return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0;
@@ -0,0 +1,53 @@
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace EchoHub.Server.Services;
public class SignalRBroadcaster(
IHubContext<ChatHub, IEchoHubClient> hubContext,
PresenceTracker presenceTracker) : IChatBroadcaster
{
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
=> hubContext.Clients.Group(channelName).ReceiveMessage(message);
public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
{
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
return hubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username);
return hubContext.Clients.Group(channelName).UserJoined(channelName, username);
}
public Task SendUserLeftAsync(string channelName, string username)
=> hubContext.Clients.Group(channelName).UserLeft(channelName, username);
public Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
{
if (channelName is not null)
return hubContext.Clients.Group(channelName).ChannelUpdated(channel);
return hubContext.Clients.All.ChannelUpdated(channel);
}
public Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence)
{
var connections = presenceTracker.GetConnectionsInChannels(channelNames)
.Where(c => !c.StartsWith("irc-"))
.ToList();
if (connections.Count == 0)
return Task.CompletedTask;
return hubContext.Clients.Clients(connections).UserStatusChanged(presence);
}
public Task SendErrorAsync(string connectionId, string message)
{
if (connectionId.StartsWith("irc-"))
return Task.CompletedTask;
return hubContext.Clients.Client(connectionId).Error(message);
}
}
@@ -14,6 +14,16 @@
"PublicServer": false, "PublicServer": false,
"PublicHost": "" "PublicHost": ""
}, },
"Irc": {
"Enabled": false,
"Port": 6667,
"TlsEnabled": false,
"TlsPort": 6697,
"TlsCertPath": "",
"TlsCertPassword": "",
"ServerName": "echohub",
"Motd": "Welcome to EchoHub IRC Gateway!"
},
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
"Default": "Information", "Default": "Information",
+1
View File
@@ -2,5 +2,6 @@
<Project Path="EchoHub.Client/EchoHub.Client.csproj" /> <Project Path="EchoHub.Client/EchoHub.Client.csproj" />
<Project Path="EchoHub.Core/EchoHub.Core.csproj" /> <Project Path="EchoHub.Core/EchoHub.Core.csproj" />
<Project Path="EchoHub.Server/EchoHub.Server.csproj" /> <Project Path="EchoHub.Server/EchoHub.Server.csproj" />
<Project Path="EchoHub.Server.Irc/EchoHub.Server.Irc.csproj" />
<Project Path="EchoHub.Tests/EchoHub.Tests.csproj" /> <Project Path="EchoHub.Tests/EchoHub.Tests.csproj" />
</Solution> </Solution>