mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-05 15:46:01 +02:00
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:
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user