From cb6b9d1e55f5083a7b375a66ee07586208212fd0 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 11:31:20 +0100 Subject: [PATCH] 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. --- .../Contracts/IChatBroadcaster.cs | 13 + src/EchoHub.Core/Contracts/IChatService.cs | 36 + .../EchoHub.Server.Irc.csproj | 18 + src/EchoHub.Server.Irc/IrcBroadcaster.cs | 67 ++ src/EchoHub.Server.Irc/IrcClientConnection.cs | 87 +++ src/EchoHub.Server.Irc/IrcCommandHandler.cs | 641 ++++++++++++++++++ src/EchoHub.Server.Irc/IrcGatewayService.cs | 157 +++++ src/EchoHub.Server.Irc/IrcMessage.cs | 69 ++ src/EchoHub.Server.Irc/IrcMessageFormatter.cs | 80 +++ src/EchoHub.Server.Irc/IrcNumericReply.cs | 65 ++ src/EchoHub.Server.Irc/IrcOptions.cs | 15 + .../IrcServiceExtensions.cs | 26 + .../Controllers/ChannelsController.cs | 12 +- src/EchoHub.Server/EchoHub.Server.csproj | 1 + src/EchoHub.Server/Hubs/ChatHub.cs | 225 +----- src/EchoHub.Server/Program.cs | 10 + src/EchoHub.Server/Services/ChatService.cs | 330 +++++++++ .../Services/PresenceTracker.cs | 5 + .../Services/SignalRBroadcaster.cs | 53 ++ src/EchoHub.Server/appsettings.example.json | 10 + src/EchoHub.slnx | 1 + 21 files changed, 1706 insertions(+), 215 deletions(-) create mode 100644 src/EchoHub.Core/Contracts/IChatBroadcaster.cs create mode 100644 src/EchoHub.Core/Contracts/IChatService.cs create mode 100644 src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj create mode 100644 src/EchoHub.Server.Irc/IrcBroadcaster.cs create mode 100644 src/EchoHub.Server.Irc/IrcClientConnection.cs create mode 100644 src/EchoHub.Server.Irc/IrcCommandHandler.cs create mode 100644 src/EchoHub.Server.Irc/IrcGatewayService.cs create mode 100644 src/EchoHub.Server.Irc/IrcMessage.cs create mode 100644 src/EchoHub.Server.Irc/IrcMessageFormatter.cs create mode 100644 src/EchoHub.Server.Irc/IrcNumericReply.cs create mode 100644 src/EchoHub.Server.Irc/IrcOptions.cs create mode 100644 src/EchoHub.Server.Irc/IrcServiceExtensions.cs create mode 100644 src/EchoHub.Server/Services/ChatService.cs create mode 100644 src/EchoHub.Server/Services/SignalRBroadcaster.cs diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs new file mode 100644 index 0000000..6e2a11c --- /dev/null +++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs @@ -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 channelNames, UserPresenceDto presence); + Task SendErrorAsync(string connectionId, string message); +} diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs new file mode 100644 index 0000000..c4102fb --- /dev/null +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -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 UserDisconnectedAsync(string connectionId); + + // Channel operations + Task<(List History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName); + Task LeaveChannelAsync(string connectionId, string username, string channelName); + + // Messaging + Task SendMessageAsync(Guid userId, string username, string channelName, string content); + Task> GetChannelHistoryAsync(string channelName, int count); + + // Presence + Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage); + Task> 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 GetUserProfileAsync(string username); + Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName); + Task> GetChannelListAsync(); + Task> GetChannelsForUserAsync(string username); + Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); +} + +public record ChannelListItem(string Name, string? Topic, int OnlineCount); diff --git a/src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj b/src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj new file mode 100644 index 0000000..750fb0f --- /dev/null +++ b/src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs new file mode 100644 index 0000000..9f70d3d --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -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 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}"); + } + } +} diff --git a/src/EchoHub.Server.Irc/IrcClientConnection.cs b/src/EchoHub.Server.Irc/IrcClientConnection.cs new file mode 100644 index 0000000..8566268 --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcClientConnection.cs @@ -0,0 +1,87 @@ +using System.Net.Sockets; +using System.Text; + +namespace EchoHub.Server.Irc; + +/// +/// Manages a single IRC client TCP connection. +/// +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 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 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(); + } +} diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs new file mode 100644 index 0000000..1bf1a6b --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -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; + } +} diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs new file mode 100644 index 0000000..65c02ae --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -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 _logger; + private readonly ConcurrentDictionary _connections = new(); + + public IrcOptions Options => _options; + public IReadOnlyDictionary Connections => _connections; + + public IrcGatewayService( + IOptions options, + IChatService chatService, + ILogger logger) + { + _options = options.Value; + _chatService = chatService; + _logger = logger; + } + + public IEnumerable 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(); + + 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); + } +} diff --git a/src/EchoHub.Server.Irc/IrcMessage.cs b/src/EchoHub.Server.Irc/IrcMessage.cs new file mode 100644 index 0000000..f84160f --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcMessage.cs @@ -0,0 +1,69 @@ +namespace EchoHub.Server.Irc; + +/// +/// Parsed representation of an IRC protocol line. +/// Format: [:prefix] COMMAND [params...] [:trailing] +/// +public sealed class IrcMessage +{ + public string? Prefix { get; init; } + public string Command { get; init; } = ""; + public List Parameters { get; init; } = []; + + public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null; + + /// + /// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF + /// + 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(); + 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, + }; + } +} diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs new file mode 100644 index 0000000..a161313 --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -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; + + /// + /// Format a MessageDto as one or more IRC PRIVMSG lines. + /// + public static List FormatMessage(MessageDto message) + { + var lines = new List(); + 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; + } + + /// + /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. + /// + public static List SplitMessage(string content, int maxBytes) + { + if (Encoding.UTF8.GetByteCount(content) <= maxBytes) + return [content]; + + var chunks = new List(); + 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; + } +} diff --git a/src/EchoHub.Server.Irc/IrcNumericReply.cs b/src/EchoHub.Server.Irc/IrcNumericReply.cs new file mode 100644 index 0000000..60fdb21 --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcNumericReply.cs @@ -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"; +} diff --git a/src/EchoHub.Server.Irc/IrcOptions.cs b/src/EchoHub.Server.Irc/IrcOptions.cs new file mode 100644 index 0000000..2bfa9bd --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcOptions.cs @@ -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; } +} diff --git a/src/EchoHub.Server.Irc/IrcServiceExtensions.cs b/src/EchoHub.Server.Irc/IrcServiceExtensions.cs new file mode 100644 index 0000000..14c096b --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcServiceExtensions.cs @@ -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( + builder.Configuration.GetSection(IrcOptions.SectionName)); + + if (builder.Configuration.GetValue("Irc:Enabled")) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + new IrcBroadcaster(sp.GetRequiredService())); + builder.Services.AddHostedService(sp => + sp.GetRequiredService()); + } + + return builder; + } +} diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 93f4770..5d853d0 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -4,12 +4,10 @@ using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Data; -using EchoHub.Server.Hubs; using EchoHub.Server.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; namespace EchoHub.Server.Controllers; @@ -23,7 +21,7 @@ public class ChannelsController( FileStorageService fileStorage, ImageToAsciiService asciiService, IHttpClientFactory httpClientFactory, - IHubContext hubContext) : ControllerBase + IChatService chatService) : ControllerBase { [HttpGet] public async Task GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) @@ -78,7 +76,7 @@ public class ChannelsController( await db.SaveChangesAsync(); 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); } @@ -107,7 +105,7 @@ public class ChannelsController( var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); 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); } @@ -214,7 +212,7 @@ public class ChannelsController( file.FileName, message.SentAt); - await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); + await chatService.BroadcastMessageAsync(channelName, messageDto); return Ok(messageDto); } @@ -331,7 +329,7 @@ public class ChannelsController( fileName, message.SentAt); - await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); + await chatService.BroadcastMessageAsync(channelName, messageDto); return Ok(messageDto); } diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj index 446b65c..93bbe03 100644 --- a/src/EchoHub.Server/EchoHub.Server.csproj +++ b/src/EchoHub.Server/EchoHub.Server.csproj @@ -2,6 +2,7 @@ + diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index c9c42de..eac6401 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -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 logger, PresenceTracker presenceTracker) : Hub +public class ChatHub(IChatService chatService, ILogger logger) : Hub { private Guid CurrentUserId => Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier) @@ -26,19 +23,8 @@ public class ChatHub(EchoHubDbContext db, ILogger 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 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 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 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 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 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 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 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) { diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index ccbae80..33ca3b8 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -1,10 +1,12 @@ using System.Text; using System.Threading.RateLimiting; using EchoHub.Core.Constants; +using EchoHub.Core.Contracts; using EchoHub.Core.Models; using EchoHub.Server.Auth; using EchoHub.Server.Data; using EchoHub.Server.Hubs; +using EchoHub.Server.Irc; using EchoHub.Server.Services; using EchoHub.Server.Setup; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -101,6 +103,14 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); + + // ── Chat Service + Broadcasters ───────────────────────────────────── + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // ── IRC Gateway (optional) ────────────────────────────────────────── + builder.AddIrcGateway(); + builder.Services.AddHttpClient("ImageDownload", client => { client.Timeout = TimeSpan.FromSeconds(15); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs new file mode 100644 index 0000000..900adcc --- /dev/null +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -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 broadcasters, + ILogger 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(); + + 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); + + 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(); + + 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(); + + 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 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(); + + 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> 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(); + + return await GetChannelHistoryInternalAsync(db, channelName, count); + } + + 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); + + 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)) + .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 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 GetUserProfileAsync(string username) + { + username = username.ToLowerInvariant(); + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (channel is null) return (null, false); + + return (channel.Topic, true); + } + + public async Task> GetChannelListAsync() + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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> 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(); + + 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> 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; + } +} diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index eccb9f4..627718d 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -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) { return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs new file mode 100644 index 0000000..29d29a5 --- /dev/null +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -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 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 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); + } +} diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index 46200f0..16d0607 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -14,6 +14,16 @@ "PublicServer": false, "PublicHost": "" }, + "Irc": { + "Enabled": false, + "Port": 6667, + "TlsEnabled": false, + "TlsPort": 6697, + "TlsCertPath": "", + "TlsCertPassword": "", + "ServerName": "echohub", + "Motd": "Welcome to EchoHub IRC Gateway!" + }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/src/EchoHub.slnx b/src/EchoHub.slnx index 6abeb04..6478113 100644 --- a/src/EchoHub.slnx +++ b/src/EchoHub.slnx @@ -2,5 +2,6 @@ +