Files
EchoHub/src/EchoHub.Server.Irc/IrcCommandHandler.cs
T

649 lines
23 KiB
C#

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 IChannelService _channelService;
private readonly IMessageEncryptionService _encryption;
private readonly ILogger _logger;
private string ServerName => _options.ServerName;
public IrcCommandHandler(
IrcClientConnection conn,
IrcOptions options,
IChatService chatService,
IChannelService channelService,
IMessageEncryptionService encryption,
ILogger logger)
{
_conn = conn;
_options = options;
_chatService = chatService;
_channelService = channelService;
_encryption = encryption;
_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 (!await RequireRegisteredAsync()) 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.JoinChannel(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 (decrypt — history is encrypted for SignalR transport)
foreach (var m in history)
{
var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
var lines = IrcMessageFormatter.FormatMessage(decrypted);
foreach (var line in lines)
await _conn.SendAsync(line);
}
}
}
private async Task HandlePartAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) 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.LeaveChannel(channelName);
await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
(partMessage is not null ? $" :{partMessage}" : ""));
}
}
private async Task HandlePrivmsgAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) 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 (!await RequireRegisteredAsync()) 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 (!await RequireRegisteredAsync()) 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 _channelService.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 (!await RequireRegisteredAsync()) 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 (!await RequireRegisteredAsync()) 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 (!await RequireRegisteredAsync()) 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 (!await RequireRegisteredAsync()) return;
var channels = await _channelService.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 (!await RequireRegisteredAsync()) 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 async Task<bool> RequireRegisteredAsync()
{
if (_conn.IsRegistered) return true;
await _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;
}
}