mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Add support for IRCv3 server-time, message-tags, echo-message, batch, draft/multiline extensions
This commit is contained in:
@@ -23,28 +23,88 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
Content = _encryption.Decrypt(message.Content),
|
Content = _encryption.Decrypt(message.Content),
|
||||||
ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
|
ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
|
||||||
};
|
};
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
|
var baseLines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
|
||||||
|
|
||||||
|
// Compute shared tag components (same for all connections in this channel)
|
||||||
|
var serverTimeTag = message.SentAt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
|
||||||
|
var msgid = message.Id.ToString("D");
|
||||||
|
var replyMsgid = message.ReplyTo?.MessageId.ToString("D");
|
||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
// IRC convention: don't echo a message back to the connection that sent it
|
// Don't echo back to traditional IRC clients (they display locally).
|
||||||
// (its client already displayed it locally). Match by connection id, not
|
// Echo-message clients need their own messages back for msgid/+reply tracking.
|
||||||
// nickname — the same account may also be online via the TUI or a second
|
if (conn.ConnectionId == excludeConnectionId && !conn.HasCap("echo-message"))
|
||||||
// IRC client, and those sessions must still receive the message.
|
|
||||||
if (conn.ConnectionId == excludeConnectionId)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
foreach (var line in lines)
|
// Build per-connection tags
|
||||||
await conn.SendAsync(line);
|
var tags = new List<(string Key, string? Value)>();
|
||||||
|
|
||||||
|
if (conn.HasCap("server-time"))
|
||||||
|
tags.Add(("time", serverTimeTag));
|
||||||
|
|
||||||
|
if (conn.HasCap("message-tags"))
|
||||||
|
{
|
||||||
|
tags.Add(("msgid", msgid));
|
||||||
|
if (replyMsgid is not null)
|
||||||
|
tags.Add(("+reply", replyMsgid));
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
|
||||||
|
|
||||||
|
if (conn.HasCap("draft/multiline") && conn.HasCap("batch") && baseLines.Count > 1)
|
||||||
|
{
|
||||||
|
await SendMultilineBatchAsync(conn, channelName, tagPrefix, baseLines);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var line in baseLines)
|
||||||
|
await conn.SendAsync(tagPrefix + line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SendMultilineBatchAsync(
|
||||||
|
IrcClientConnection conn, string channelName, string tagPrefix, List<string> lines)
|
||||||
|
{
|
||||||
|
if (lines.Count == 0) return;
|
||||||
|
|
||||||
|
var batchRef = $"ml{Guid.NewGuid().ToString("N")[..8]}";
|
||||||
|
var ircChannel = $"#{channelName}";
|
||||||
|
|
||||||
|
var sharedTags = tagPrefix.Length > 0
|
||||||
|
? tagPrefix.TrimEnd() + ";" + IrcMessage.BuildTagPrefix(("batch", batchRef)).TrimEnd()
|
||||||
|
: IrcMessage.BuildTagPrefix(("batch", batchRef)).TrimEnd();
|
||||||
|
var linePrefix = $"@{sharedTags} ";
|
||||||
|
|
||||||
|
// Extract the sender prefix from the first line
|
||||||
|
var firstLine = lines[0];
|
||||||
|
var senderPrefix = firstLine.StartsWith(':')
|
||||||
|
? firstLine[1..firstLine.IndexOf(' ')]
|
||||||
|
: _gateway.Options.ServerName;
|
||||||
|
|
||||||
|
var batchTags = tagPrefix.Length > 0 ? tagPrefix : "";
|
||||||
|
await conn.SendAsync($"{batchTags}:{senderPrefix} BATCH +{batchRef} draft/multiline {ircChannel}");
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
await conn.SendAsync($"{linePrefix}{line}");
|
||||||
|
|
||||||
|
await conn.SendAsync($"BATCH -{batchRef}");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
public async Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
if (conn.ConnectionId == excludeConnectionId) continue;
|
if (conn.ConnectionId == excludeConnectionId) continue;
|
||||||
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
|
var line = $":{username}!{username}@echohub JOIN #{channelName}";
|
||||||
|
|
||||||
|
var tags = new List<(string Key, string? Value)>();
|
||||||
|
if (conn.HasCap("server-time"))
|
||||||
|
tags.Add(("time", DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||||
|
|
||||||
|
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
|
||||||
|
await conn.SendAsync(tagPrefix + line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +113,14 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
if (conn.Nickname == username) continue;
|
if (conn.Nickname == username) continue;
|
||||||
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
|
var line = $":{username}!{username}@echohub PART #{channelName}";
|
||||||
|
|
||||||
|
var tags = new List<(string Key, string? Value)>();
|
||||||
|
if (conn.HasCap("server-time"))
|
||||||
|
tags.Add(("time", DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||||
|
|
||||||
|
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
|
||||||
|
await conn.SendAsync(tagPrefix + line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +131,14 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(target))
|
foreach (var conn in _gateway.GetConnectionsInChannel(target))
|
||||||
{
|
{
|
||||||
await conn.SendAsync($":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
|
var line = $":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}";
|
||||||
|
|
||||||
|
var tags = new List<(string Key, string? Value)>();
|
||||||
|
if (conn.HasCap("server-time"))
|
||||||
|
tags.Add(("time", DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||||
|
|
||||||
|
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
|
||||||
|
await conn.SendAsync(tagPrefix + line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,22 @@ public sealed class IrcClientConnection : IAsyncDisposable
|
|||||||
public bool IsSasl { get; set; }
|
public bool IsSasl { get; set; }
|
||||||
public bool CapNegotiating { get; set; }
|
public bool CapNegotiating { get; set; }
|
||||||
|
|
||||||
|
// Highest CAP LS version received from client (0 = no version)
|
||||||
|
public int CapVersion { get; set; }
|
||||||
|
|
||||||
|
public HashSet<string> EnabledCaps { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public bool HasCap(string cap) => EnabledCaps.Contains(cap);
|
||||||
|
public void EnableCap(string cap) => EnabledCaps.Add(cap);
|
||||||
|
public void DisableCap(string cap) => EnabledCaps.Remove(cap);
|
||||||
|
|
||||||
// Channel state — thread-safe: written by command handler, read by broadcaster threads
|
// Channel state — thread-safe: written by command handler, read by broadcaster threads
|
||||||
private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly object _channelLock = new();
|
private readonly object _channelLock = new();
|
||||||
|
|
||||||
|
// Non-null while BATCH lines are being collected
|
||||||
|
public MultilineBatchContext? PendingMultilineBatch { get; set; }
|
||||||
|
|
||||||
// Away state
|
// Away state
|
||||||
public string? AwayMessage { get; set; }
|
public string? AwayMessage { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private string ServerName => _options.ServerName;
|
private string ServerName => _options.ServerName;
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string?> ServerCaps = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["sasl"] = null,
|
||||||
|
["server-time"] = null,
|
||||||
|
["message-tags"] = null,
|
||||||
|
["echo-message"] = null,
|
||||||
|
["batch"] = null,
|
||||||
|
["draft/multiline"] = "max-bytes=40000,max-lines=10",
|
||||||
|
};
|
||||||
|
|
||||||
public IrcCommandHandler(
|
public IrcCommandHandler(
|
||||||
IrcClientConnection conn,
|
IrcClientConnection conn,
|
||||||
IrcOptions options,
|
IrcOptions options,
|
||||||
@@ -82,6 +92,7 @@ public sealed class IrcCommandHandler
|
|||||||
"JOIN" => HandleJoinAsync(msg),
|
"JOIN" => HandleJoinAsync(msg),
|
||||||
"PART" => HandlePartAsync(msg),
|
"PART" => HandlePartAsync(msg),
|
||||||
"PRIVMSG" => HandlePrivmsgAsync(msg),
|
"PRIVMSG" => HandlePrivmsgAsync(msg),
|
||||||
|
"NOTICE" => Task.CompletedTask,
|
||||||
"QUIT" => HandleQuitAsync(msg),
|
"QUIT" => HandleQuitAsync(msg),
|
||||||
"NAMES" => HandleNamesAsync(msg),
|
"NAMES" => HandleNamesAsync(msg),
|
||||||
"TOPIC" => HandleTopicAsync(msg),
|
"TOPIC" => HandleTopicAsync(msg),
|
||||||
@@ -93,36 +104,46 @@ public sealed class IrcCommandHandler
|
|||||||
"MOTD" => SendMotdAsync(),
|
"MOTD" => SendMotdAsync(),
|
||||||
"USERHOST" or "LUSERS" => Task.CompletedTask,
|
"USERHOST" or "LUSERS" => Task.CompletedTask,
|
||||||
|
|
||||||
|
// IRCv3 multiline batch
|
||||||
|
"BATCH" => HandleBatchAsync(msg),
|
||||||
|
|
||||||
_ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND,
|
_ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND,
|
||||||
$"{command} :Unknown command"),
|
$"{command} :Unknown command"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Authentication ──────────────────────────────────────────────────────
|
// ── IRCv3 CAP Negotiation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static readonly string[] CapList302 = [.. ServerCaps.Select(kvp =>
|
||||||
|
kvp.Value is not null ? $"{kvp.Key}={kvp.Value}" : kvp.Key)];
|
||||||
|
|
||||||
|
private static readonly string[] CapListLegacy = [.. ServerCaps.Keys];
|
||||||
|
|
||||||
private async Task HandleCapAsync(IrcMessage msg)
|
private async Task HandleCapAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1)
|
||||||
|
{
|
||||||
|
await SendInvalidCapCmdAsync("CAP requires a subcommand");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (msg.Parameters[0].ToUpperInvariant())
|
var subcommand = msg.Parameters[0].ToUpperInvariant();
|
||||||
|
|
||||||
|
// Build the nick placeholder for server responses (use * while unregistered)
|
||||||
|
var nick = _conn.Nickname ?? "*";
|
||||||
|
|
||||||
|
switch (subcommand)
|
||||||
{
|
{
|
||||||
case "LS":
|
case "LS":
|
||||||
await _conn.SendAsync($":{ServerName} CAP * LS :sasl");
|
await HandleCapLsAsync(msg, nick);
|
||||||
_conn.CapNegotiating = true;
|
break;
|
||||||
|
|
||||||
|
case "LIST":
|
||||||
|
await HandleCapListAsync(nick);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "REQ":
|
case "REQ":
|
||||||
if (msg.Parameters.Count >= 2 &&
|
await HandleCapReqAsync(msg, nick);
|
||||||
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;
|
break;
|
||||||
|
|
||||||
case "END":
|
case "END":
|
||||||
@@ -130,9 +151,232 @@ public sealed class IrcCommandHandler
|
|||||||
if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered)
|
if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered)
|
||||||
await TryCompleteRegistrationAsync();
|
await TryCompleteRegistrationAsync();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
await SendInvalidCapCmdAsync($"Unknown subcommand {subcommand}");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleCapLsAsync(IrcMessage msg, string nick)
|
||||||
|
{
|
||||||
|
// Parse optional version argument
|
||||||
|
var version = 0;
|
||||||
|
if (msg.Parameters.Count >= 2 && int.TryParse(msg.Parameters[1], out var v))
|
||||||
|
version = v;
|
||||||
|
|
||||||
|
// Store the highest version seen (clients cannot downgrade)
|
||||||
|
if (version > _conn.CapVersion)
|
||||||
|
_conn.CapVersion = version;
|
||||||
|
|
||||||
|
// Decide capability list format based on negotiated version
|
||||||
|
var caps = version >= 302 ? CapList302 : CapListLegacy;
|
||||||
|
|
||||||
|
// Suspend registration during CAP negotiation
|
||||||
|
_conn.CapNegotiating = true;
|
||||||
|
|
||||||
|
// Multiline CAP LS 302 response
|
||||||
|
if (version >= 302 && caps.Length > 0)
|
||||||
|
{
|
||||||
|
// If the total fits in one line, send it as a single reply
|
||||||
|
var singleLine = string.Join(" ", caps);
|
||||||
|
if (singleLine.Length < 400)
|
||||||
|
{
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} LS :{singleLine}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Split across multiple lines; all but the last get '*' as a marker
|
||||||
|
var lines = SplitCapList(caps, 400);
|
||||||
|
for (var i = 0; i < lines.Count; i++)
|
||||||
|
{
|
||||||
|
var marker = i < lines.Count - 1 ? "*" : "";
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} LS {marker}:{lines[i]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (caps.Length > 0)
|
||||||
|
{
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} LS :{string.Join(" ", caps)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} LS :");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCapListAsync(string nick)
|
||||||
|
{
|
||||||
|
var enabled = _conn.EnabledCaps.ToArray();
|
||||||
|
if (enabled.Length > 0)
|
||||||
|
{
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} LIST :{string.Join(" ", enabled)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} LIST :");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCapReqAsync(IrcMessage msg, string nick)
|
||||||
|
{
|
||||||
|
if (msg.Parameters.Count < 2 || string.IsNullOrWhiteSpace(msg.Parameters[1]))
|
||||||
|
{
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} NAK :");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requested = msg.Parameters[1].Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
// Validate all caps are known before modifying anything (all-or-nothing)
|
||||||
|
var ackList = new List<string>();
|
||||||
|
var valid = true;
|
||||||
|
|
||||||
|
foreach (var item in requested)
|
||||||
|
{
|
||||||
|
var cap = item;
|
||||||
|
if (cap.StartsWith('-'))
|
||||||
|
cap = cap[1..];
|
||||||
|
|
||||||
|
// cap-notify is implicitly enabled for CAP LS 302; accept it silently
|
||||||
|
if (cap.Equals("cap-notify", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!ServerCaps.ContainsKey(cap))
|
||||||
|
{
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid)
|
||||||
|
{
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} NAK :{msg.Parameters[1]}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in requested)
|
||||||
|
{
|
||||||
|
if (item.StartsWith('-'))
|
||||||
|
{
|
||||||
|
_conn.DisableCap(item[1..]);
|
||||||
|
ackList.Add(item);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_conn.EnableCap(item);
|
||||||
|
ackList.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ackList.Count > 0)
|
||||||
|
{
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} ACK :{string.Join(" ", ackList)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No actual caps to ack (e.g. cap-notify only) — send empty ACK
|
||||||
|
await _conn.SendAsync($":{ServerName} CAP {nick} ACK :");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendInvalidCapCmdAsync(string message)
|
||||||
|
{
|
||||||
|
var nick = _conn.Nickname ?? "*";
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_INVALIDCAPCMD, nick, $"{message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> SplitCapList(string[] caps, int maxLen)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
var current = new List<string>();
|
||||||
|
var currentLen = 0;
|
||||||
|
|
||||||
|
foreach (var cap in caps)
|
||||||
|
{
|
||||||
|
var capLen = cap.Length + (current.Count > 0 ? 1 : 0); // +1 for leading space
|
||||||
|
if (currentLen + capLen > maxLen && current.Count > 0)
|
||||||
|
{
|
||||||
|
lines.Add(string.Join(" ", current));
|
||||||
|
current.Clear();
|
||||||
|
currentLen = 0;
|
||||||
|
capLen = cap.Length;
|
||||||
|
}
|
||||||
|
current.Add(cap);
|
||||||
|
currentLen += capLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Count > 0)
|
||||||
|
lines.Add(string.Join(" ", current));
|
||||||
|
|
||||||
|
return lines.Count > 0 ? lines : [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IRCv3 Multiline Batch ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task HandleBatchAsync(IrcMessage msg)
|
||||||
|
{
|
||||||
|
if (!await RequireRegisteredAsync()) return;
|
||||||
|
if (msg.Parameters.Count < 1) return;
|
||||||
|
|
||||||
|
var reference = msg.Parameters[0];
|
||||||
|
|
||||||
|
if (reference.StartsWith('-'))
|
||||||
|
{
|
||||||
|
// BATCH -ref → end of batch
|
||||||
|
var batch = _conn.PendingMultilineBatch;
|
||||||
|
if (batch is null || batch.ReferenceTag != reference[1..])
|
||||||
|
return;
|
||||||
|
|
||||||
|
_conn.PendingMultilineBatch = null;
|
||||||
|
await FlushMultilineBatchAsync(batch);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// BATCH +ref type [target]
|
||||||
|
if (msg.Parameters.Count < 2) return;
|
||||||
|
var type = msg.Parameters[1];
|
||||||
|
|
||||||
|
if (!type.Equals("draft/multiline", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (msg.Parameters.Count < 3) return;
|
||||||
|
var target = msg.Parameters[2];
|
||||||
|
|
||||||
|
var channelName = IrcToEchoHubChannel(target);
|
||||||
|
if (channelName is null) return;
|
||||||
|
|
||||||
|
_conn.PendingMultilineBatch = new MultilineBatchContext(reference[1..], channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FlushMultilineBatchAsync(MultilineBatchContext batch)
|
||||||
|
{
|
||||||
|
if (batch.Lines.Count == 0) return;
|
||||||
|
|
||||||
|
// Validate: no blank lines with concat tag, no entirely blank messages
|
||||||
|
var allBlank = true;
|
||||||
|
for (var i = 0; i < batch.Lines.Count; i++)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(batch.Lines[i]))
|
||||||
|
{
|
||||||
|
allBlank = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allBlank) return;
|
||||||
|
|
||||||
|
// Per spec: lines joined by \n by default; draft/multiline-concat lines
|
||||||
|
// are directly concatenated (already handled during collection).
|
||||||
|
var content = string.Join("\n", batch.Lines);
|
||||||
|
|
||||||
|
await _chatService.SendMessageAsync(
|
||||||
|
_conn.UserId!.Value, _conn.Nickname!, batch.Target, content, _conn.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authentication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task HandleAuthenticateAsync(IrcMessage msg)
|
private async Task HandleAuthenticateAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
@@ -328,8 +572,26 @@ public sealed class IrcCommandHandler
|
|||||||
$":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}");
|
$":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}");
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
|
||||||
$"{ServerName} EchoHub-IRC o o");
|
$"{ServerName} EchoHub-IRC o o");
|
||||||
|
|
||||||
|
var isupportTokens = new List<string>
|
||||||
|
{
|
||||||
|
"CHANTYPES=#",
|
||||||
|
"CHANMODES=b,k,,,",
|
||||||
|
"NICKLEN=50",
|
||||||
|
"CHANNELLEN=100",
|
||||||
|
"CLIENTTAGDENY=*,-reply",
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the client has message-tags, advertise CLIENTTAGDENY
|
||||||
|
if (_conn.HasCap("message-tags"))
|
||||||
|
{
|
||||||
|
isupportTokens.Add("CLIENTTAGDENY=*,-reply");
|
||||||
|
}
|
||||||
|
|
||||||
|
isupportTokens.Add(":are supported by this server");
|
||||||
|
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
|
||||||
"CHANTYPES=# CHANMODES=b,k,, NICKLEN=50 CHANNELLEN=100 :are supported by this server");
|
string.Join(" ", isupportTokens));
|
||||||
|
|
||||||
await SendMotdAsync();
|
await SendMotdAsync();
|
||||||
}
|
}
|
||||||
@@ -449,7 +711,7 @@ public sealed class IrcCommandHandler
|
|||||||
};
|
};
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
|
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
await _conn.SendAsync(line);
|
await _conn.SendAsync(DecorateLineForConnection(line, m));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,6 +761,34 @@ public sealed class IrcCommandHandler
|
|||||||
var channelName = IrcToEchoHubChannel(target);
|
var channelName = IrcToEchoHubChannel(target);
|
||||||
if (channelName is null) return;
|
if (channelName is null) return;
|
||||||
|
|
||||||
|
// Check if we're inside a multiline batch
|
||||||
|
var batch = _conn.PendingMultilineBatch;
|
||||||
|
if (batch is not null)
|
||||||
|
{
|
||||||
|
if (batch.Target != channelName)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Collect this line. If the message has the draft/multiline-concat tag,
|
||||||
|
// it appends directly without a newline separator.
|
||||||
|
var isConcat = msg.Tags.ContainsKey("draft/multiline-concat");
|
||||||
|
if (isConcat)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(content))
|
||||||
|
return; // blank concat lines not allowed
|
||||||
|
batch.UsesConcat = true;
|
||||||
|
// Append to the last line (or start a new one)
|
||||||
|
if (batch.Lines.Count > 0)
|
||||||
|
batch.Lines[^1] += content;
|
||||||
|
else
|
||||||
|
batch.Lines.Add(content);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
batch.Lines.Add(content);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var error = await _chatService.SendMessageAsync(
|
var error = await _chatService.SendMessageAsync(
|
||||||
_conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId);
|
_conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId);
|
||||||
|
|
||||||
@@ -816,4 +1106,24 @@ public sealed class IrcCommandHandler
|
|||||||
var name = ircChannel[1..].ToLowerInvariant().Trim();
|
var name = ircChannel[1..].ToLowerInvariant().Trim();
|
||||||
return ValidationConstants.ChannelNameRegex().IsMatch(name) ? name : null;
|
return ValidationConstants.ChannelNameRegex().IsMatch(name) ? name : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string DecorateLineForConnection(string line, MessageDto message)
|
||||||
|
{
|
||||||
|
var tags = new List<(string Key, string? Value)>();
|
||||||
|
|
||||||
|
if (_conn.HasCap("server-time"))
|
||||||
|
tags.Add(("time", message.SentAt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||||
|
|
||||||
|
if (_conn.HasCap("message-tags"))
|
||||||
|
{
|
||||||
|
tags.Add(("msgid", message.Id.ToString("D")));
|
||||||
|
if (message.ReplyTo is not null)
|
||||||
|
tags.Add(("+reply", message.ReplyTo.MessageId.ToString("D")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.Count == 0)
|
||||||
|
return line;
|
||||||
|
|
||||||
|
return IrcMessage.BuildTagPrefix([.. tags]) + line;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parsed representation of an IRC protocol line.
|
/// Parsed representation of an IRC protocol line.
|
||||||
/// Format: [:prefix] COMMAND [params...] [:trailing]
|
/// IRCv3 format: ['@' tags ' '] [':' prefix ' '] COMMAND [params...] [':' trailing]
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IrcMessage
|
public sealed class IrcMessage
|
||||||
{
|
{
|
||||||
|
public Dictionary<string, string?> Tags { get; init; } = new();
|
||||||
public string? Prefix { get; init; }
|
public string? Prefix { get; init; }
|
||||||
public string Command { get; init; } = "";
|
public string Command { get; init; } = "";
|
||||||
public List<string> Parameters { get; init; } = [];
|
public List<string> Parameters { get; init; } = [];
|
||||||
@@ -13,29 +16,64 @@ public sealed class IrcMessage
|
|||||||
public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
|
public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF
|
/// Parse a raw IRC line with optional IRCv3 message tags.
|
||||||
|
/// Format: ['@' tags ' '] [':' prefix ' '] COMMAND params CRLF
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IrcMessage Parse(string line)
|
public static IrcMessage Parse(string line)
|
||||||
{
|
{
|
||||||
var span = line.AsSpan().TrimEnd("\r\n");
|
var span = line.AsSpan().TrimEnd("\r\n");
|
||||||
string? prefix = null;
|
|
||||||
var pos = 0;
|
var pos = 0;
|
||||||
|
|
||||||
// Parse optional prefix
|
if (span.Length == 0)
|
||||||
if (span.Length > 0 && span[0] == ':')
|
return new IrcMessage { Command = "" };
|
||||||
|
|
||||||
|
var tags = new Dictionary<string, string?>();
|
||||||
|
|
||||||
|
// Parse optional IRCv3 message tags: @tag1;tag2=val2;...
|
||||||
|
if (span[pos] == '@')
|
||||||
{
|
{
|
||||||
var spaceIdx = span.IndexOf(' ');
|
var spaceIdx = span.IndexOf(' ');
|
||||||
if (spaceIdx == -1)
|
if (spaceIdx > 1)
|
||||||
return new IrcMessage { Prefix = span[1..].ToString() };
|
{
|
||||||
|
var tagSection = span[1..spaceIdx].ToString();
|
||||||
|
foreach (var rawTag in tagSection.Split(';'))
|
||||||
|
{
|
||||||
|
var eqIdx = rawTag.IndexOf('=');
|
||||||
|
if (eqIdx == -1)
|
||||||
|
{
|
||||||
|
tags[rawTag] = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var key = rawTag[..eqIdx];
|
||||||
|
var val = TagUnescape(rawTag[(eqIdx + 1)..]);
|
||||||
|
tags[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = spaceIdx + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prefix = span[1..spaceIdx].ToString();
|
string? prefix = null;
|
||||||
pos = spaceIdx + 1;
|
|
||||||
|
// Parse optional prefix
|
||||||
|
if (pos < span.Length && span[pos] == ':')
|
||||||
|
{
|
||||||
|
var spaceIdx = span[pos..].IndexOf(' ');
|
||||||
|
if (spaceIdx == -1)
|
||||||
|
return new IrcMessage { Tags = tags, Prefix = span[(pos + 1)..].ToString(), Command = "" };
|
||||||
|
|
||||||
|
prefix = span.Slice(pos + 1, spaceIdx - 1).ToString();
|
||||||
|
pos = pos + spaceIdx + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip whitespace
|
// Skip whitespace
|
||||||
while (pos < span.Length && span[pos] == ' ') pos++;
|
while (pos < span.Length && span[pos] == ' ') pos++;
|
||||||
|
|
||||||
// Parse command
|
// Parse command
|
||||||
|
if (pos >= span.Length)
|
||||||
|
return new IrcMessage { Tags = tags, Prefix = prefix, Command = "" };
|
||||||
|
|
||||||
var cmdStart = pos;
|
var cmdStart = pos;
|
||||||
while (pos < span.Length && span[pos] != ' ') pos++;
|
while (pos < span.Length && span[pos] != ' ') pos++;
|
||||||
var command = span[cmdStart..pos].ToString();
|
var command = span[cmdStart..pos].ToString();
|
||||||
@@ -61,9 +99,78 @@ public sealed class IrcMessage
|
|||||||
|
|
||||||
return new IrcMessage
|
return new IrcMessage
|
||||||
{
|
{
|
||||||
|
Tags = tags,
|
||||||
Prefix = prefix,
|
Prefix = prefix,
|
||||||
Command = command,
|
Command = command,
|
||||||
Parameters = parameters,
|
Parameters = parameters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the message tags prefix for outgoing lines.
|
||||||
|
/// Returns "@key1=val1;key2=val2 " or empty string if no tags.
|
||||||
|
/// Values are tag-escaped. Client-only tags (+prefix) pass through.
|
||||||
|
/// </summary>
|
||||||
|
public static string BuildTagPrefix(params (string Key, string? Value)[] tags)
|
||||||
|
{
|
||||||
|
if (tags.Length == 0) return "";
|
||||||
|
|
||||||
|
var sb = new StringBuilder("@");
|
||||||
|
var first = true;
|
||||||
|
foreach (var (key, value) in tags)
|
||||||
|
{
|
||||||
|
if (!first) sb.Append(';');
|
||||||
|
first = false;
|
||||||
|
sb.Append(key);
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
sb.Append('=');
|
||||||
|
sb.Append(TagEscape(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.Append(' ');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escape a tag value per IRCv3 message-tags spec.
|
||||||
|
/// ; → \:, SPACE → \s, \ → \\, CR → \r, LF → \n
|
||||||
|
/// </summary>
|
||||||
|
public static string TagEscape(string value)
|
||||||
|
{
|
||||||
|
return value
|
||||||
|
.Replace("\\", "\\\\")
|
||||||
|
.Replace(";", "\\:")
|
||||||
|
.Replace(" ", "\\s")
|
||||||
|
.Replace("\r", "\\r")
|
||||||
|
.Replace("\n", "\\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unescape a tag value per IRCv3 message-tags spec.
|
||||||
|
/// </summary>
|
||||||
|
public static string TagUnescape(string value)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(value.Length);
|
||||||
|
for (var i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
if (value[i] == '\\' && i + 1 < value.Length)
|
||||||
|
{
|
||||||
|
switch (value[i + 1])
|
||||||
|
{
|
||||||
|
case ':': sb.Append(';'); i++; break;
|
||||||
|
case 's': sb.Append(' '); i++; break;
|
||||||
|
case '\\': sb.Append('\\'); i++; break;
|
||||||
|
case 'r': sb.Append('\r'); i++; break;
|
||||||
|
case 'n': sb.Append('\n'); i++; break;
|
||||||
|
default: sb.Append(value[i]); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(value[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public static class IrcNumericReply
|
|||||||
public const string RPL_ENDOFBANLIST = "368";
|
public const string RPL_ENDOFBANLIST = "368";
|
||||||
|
|
||||||
// Errors
|
// Errors
|
||||||
|
public const string ERR_INVALIDCAPCMD = "410";
|
||||||
public const string ERR_NOSUCHNICK = "401";
|
public const string ERR_NOSUCHNICK = "401";
|
||||||
public const string ERR_NOSUCHCHANNEL = "403";
|
public const string ERR_NOSUCHCHANNEL = "403";
|
||||||
public const string ERR_CANNOTSENDTOCHAN = "404";
|
public const string ERR_CANNOTSENDTOCHAN = "404";
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks an in-progress multiline batch (draft/multiline) being collected
|
||||||
|
/// from an IRC client before dispatching as a single EchoHub message.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MultilineBatchContext(string referenceTag, string target)
|
||||||
|
{
|
||||||
|
public string ReferenceTag { get; } = referenceTag;
|
||||||
|
public string Target { get; } = target;
|
||||||
|
public List<string> Lines { get; } = [];
|
||||||
|
public bool UsesConcat { get; set; }
|
||||||
|
}
|
||||||
@@ -335,6 +335,151 @@ public class IrcBroadcasterTests
|
|||||||
|
|
||||||
// ── SendUserStatusChangedAsync ───────────────────────────────────────
|
// ── SendUserStatusChangedAsync ───────────────────────────────────────
|
||||||
|
|
||||||
|
// ── IRCv3 Tags (server-time, msgid, reply) ───────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_WithServerTimeCap_IncludesTimeTag()
|
||||||
|
{
|
||||||
|
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
conn.EnableCap("server-time");
|
||||||
|
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
||||||
|
new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.StartsWith("@time=2024-01-15T10:30:00.000Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_WithoutServerTimeCap_NoTimeTag()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.All(output, l => Assert.DoesNotContain("@time=", l));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_WithMessageTagsCap_IncludesMsgid()
|
||||||
|
{
|
||||||
|
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
conn.EnableCap("message-tags");
|
||||||
|
|
||||||
|
var msgId = Guid.NewGuid();
|
||||||
|
var message = new MessageDto(
|
||||||
|
msgId, _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains($"msgid={msgId:D}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_WithMessageTagsAndReply_IncludesReplyTag()
|
||||||
|
{
|
||||||
|
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
conn.EnableCap("message-tags");
|
||||||
|
|
||||||
|
var replyToId = Guid.NewGuid();
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("Hello!"), "alice", null, "general", DateTimeOffset.UtcNow,
|
||||||
|
ReplyTo: new ReplyRefDto(replyToId, "bob", _encryption.Encrypt("Original")));
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains($"+reply={replyToId:D}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_WithAllTags_FormatsCorrectly()
|
||||||
|
{
|
||||||
|
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
conn.EnableCap("server-time");
|
||||||
|
conn.EnableCap("message-tags");
|
||||||
|
|
||||||
|
var msgId = Guid.NewGuid();
|
||||||
|
var sentAt = new DateTimeOffset(2024, 6, 15, 14, 30, 0, TimeSpan.Zero);
|
||||||
|
var message = new MessageDto(
|
||||||
|
msgId, _encryption.Encrypt("Hey"), "alice", null, "general", sentAt);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.StartsWith("@time=2024-06-15T14:30:00.000Z;msgid="));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendUserJoined_WithServerTime_IncludesTimeTag()
|
||||||
|
{
|
||||||
|
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
conn.EnableCap("server-time");
|
||||||
|
|
||||||
|
await _broadcaster.SendUserJoinedAsync("general", "alice", null);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.StartsWith("@time=") && l.Contains("JOIN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multiline Batch ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_WithMultilineCap_WrapsInBatch()
|
||||||
|
{
|
||||||
|
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
conn.EnableCap("draft/multiline");
|
||||||
|
conn.EnableCap("batch");
|
||||||
|
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("Line1\nLine2"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
// BATCH start should use the user's prefix, not the server name
|
||||||
|
Assert.Contains(output, l => l.Contains(":alice!alice@echohub BATCH +") && l.Contains("draft/multiline"));
|
||||||
|
Assert.Contains(output, l => l.Contains("BATCH -"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_WithoutMultilineCap_SendsLinesDirectly()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("Line1\nLine2"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.All(output, l => Assert.DoesNotContain("BATCH", l));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_SingleLineWithMultilineCap_NoBatch()
|
||||||
|
{
|
||||||
|
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
conn.EnableCap("draft/multiline");
|
||||||
|
conn.EnableCap("batch");
|
||||||
|
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("Just one line"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.All(output, l => Assert.DoesNotContain("BATCH", l));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendUserStatusChanged_IsNoOp()
|
public async Task SendUserStatusChanged_IsNoOp()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -164,11 +164,59 @@ public class IrcCommandHandlerTests
|
|||||||
// ── CAP / SASL ──────────────────────────────────────────────────────
|
// ── CAP / SASL ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CapLs_AdvertisesSasl()
|
public async Task CapLs_AdvertisesCapabilities()
|
||||||
{
|
{
|
||||||
var lines = await RunAndCapture(["CAP LS"]);
|
var lines = await RunAndCapture(["CAP LS"]);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
|
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
|
||||||
|
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("server-time"));
|
||||||
|
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("message-tags"));
|
||||||
|
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("batch"));
|
||||||
|
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("draft/multiline"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CapLs302_AdvertisesCapabilitiesWithValues()
|
||||||
|
{
|
||||||
|
var lines = await RunAndCapture(["CAP LS 302"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("draft/multiline=max-bytes=40000"));
|
||||||
|
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CapLs_SuspendsRegistration()
|
||||||
|
{
|
||||||
|
_userService.AuthResult = FakeUserService.SuccessResult(Guid.NewGuid(), "alice");
|
||||||
|
|
||||||
|
var lines = await RunAndCapture([
|
||||||
|
"CAP LS",
|
||||||
|
"PASS secret123",
|
||||||
|
"NICK alice",
|
||||||
|
"USER alice 0 * :Alice"
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should NOT get welcome (001) since CAP END wasn't sent
|
||||||
|
Assert.DoesNotContain(lines, l => l.Contains("001") && l.Contains("Welcome"));
|
||||||
|
|
||||||
|
// Registration should still be pending
|
||||||
|
Assert.True(lines.All(l => !l.Contains("001")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CapEnd_ResumesRegistration()
|
||||||
|
{
|
||||||
|
_userService.AuthResult = FakeUserService.SuccessResult(Guid.NewGuid(), "alice");
|
||||||
|
|
||||||
|
var lines = await RunAndCapture([
|
||||||
|
"CAP LS",
|
||||||
|
"PASS secret123",
|
||||||
|
"NICK alice",
|
||||||
|
"USER alice 0 * :Alice",
|
||||||
|
"CAP END"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -179,12 +227,61 @@ public class IrcCommandHandlerTests
|
|||||||
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl"));
|
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CapReqMultiple_Acknowledged()
|
||||||
|
{
|
||||||
|
var lines = await RunAndCapture(["CAP REQ :server-time message-tags"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("server-time") && l.Contains("message-tags"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CapReqUnknown_GetsNak()
|
public async Task CapReqUnknown_GetsNak()
|
||||||
{
|
{
|
||||||
var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
|
var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("NAK"));
|
Assert.Contains(lines, l => l.Contains("NAK") && l.Contains("multi-prefix"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CapReqDisableCap_DisablesAndAcks()
|
||||||
|
{
|
||||||
|
var (conn, stream) = TestIrcConnectionFactory.Create(
|
||||||
|
"CAP REQ :message-tags\r\nCAP REQ :-message-tags\r\nCAP END\r\nNICK alice\r\nUSER alice 0 * :Alice\r\n".Split("\r\n", StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
|
||||||
|
var handler = new IrcCommandHandler(conn, new IrcOptions { ServerName = "testserver" },
|
||||||
|
_chatService, _userService, _channelService, _encryption, NullLogger.Instance);
|
||||||
|
await handler.RunAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
|
||||||
|
// First REQ should ACK
|
||||||
|
Assert.Contains(output, l => l.Contains("ACK") && l.Contains("message-tags"));
|
||||||
|
|
||||||
|
// Second REQ with - should ACK with - prefix
|
||||||
|
Assert.Contains(output, l => l.Contains("ACK") && l.Contains("-message-tags"));
|
||||||
|
|
||||||
|
// Cap should be disabled
|
||||||
|
Assert.False(conn.HasCap("message-tags"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CapList_AfterEnable_ShowsEnabledCaps()
|
||||||
|
{
|
||||||
|
var lines = await RunAndCapture([
|
||||||
|
"CAP REQ :server-time",
|
||||||
|
"CAP LIST"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("LIST") && l.Contains("server-time"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CapUnknownSubcommand_Gets410()
|
||||||
|
{
|
||||||
|
var lines = await RunAndCapture(["CAP FOO"]);
|
||||||
|
|
||||||
|
Assert.Contains(lines, l => l.Contains("410") && l.Contains("FOO"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -693,6 +790,49 @@ public class IrcCommandHandlerTests
|
|||||||
Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD
|
Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BATCH (Multiline) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Batch_Multiline_CollectsAndFlushes()
|
||||||
|
{
|
||||||
|
var lines = await RunAuthenticated([
|
||||||
|
"BATCH +abc123 draft/multiline #general",
|
||||||
|
"@batch=abc123 PRIVMSG #general :Hello",
|
||||||
|
"@batch=abc123 PRIVMSG #general :world",
|
||||||
|
"BATCH -abc123"
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should send a single message with lines joined by newlines
|
||||||
|
Assert.Single(_chatService.SentMessages);
|
||||||
|
Assert.Equal("general", _chatService.SentMessages[0].Channel);
|
||||||
|
Assert.Equal("Hello\nworld", _chatService.SentMessages[0].Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Batch_MultilineWithConcat_JoinsWithoutNewline()
|
||||||
|
{
|
||||||
|
var lines = await RunAuthenticated([
|
||||||
|
"BATCH +abc123 draft/multiline #general",
|
||||||
|
"@batch=abc123 PRIVMSG #general :hello ",
|
||||||
|
"@batch=abc123;draft/multiline-concat PRIVMSG #general :world",
|
||||||
|
"BATCH -abc123"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Single(_chatService.SentMessages);
|
||||||
|
Assert.Equal("hello world", _chatService.SentMessages[0].Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Batch_MultilineEmptyBatch_DoesNothing()
|
||||||
|
{
|
||||||
|
var lines = await RunAuthenticated([
|
||||||
|
"BATCH +abc123 draft/multiline #general",
|
||||||
|
"BATCH -abc123"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Empty(_chatService.SentMessages);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Unknown command ──────────────────────────────────────────────────
|
// ── Unknown command ──────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -180,4 +180,128 @@ public class IrcMessageTests
|
|||||||
var msg = IrcMessage.Parse("MODE #channel +o alice");
|
var msg = IrcMessage.Parse("MODE #channel +o alice");
|
||||||
Assert.Equal("alice", msg.Trailing);
|
Assert.Equal("alice", msg.Trailing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── IRCv3 Message Tags ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_WithTags_ExtractsTags()
|
||||||
|
{
|
||||||
|
var msg = IrcMessage.Parse("@time=2024-01-01T12:00:00.000Z;msgid=abc PRIVMSG #channel :Hello");
|
||||||
|
|
||||||
|
Assert.Equal(2, msg.Tags.Count);
|
||||||
|
Assert.Equal("2024-01-01T12:00:00.000Z", msg.Tags["time"]);
|
||||||
|
Assert.Equal("abc", msg.Tags["msgid"]);
|
||||||
|
Assert.Equal("PRIVMSG", msg.Command);
|
||||||
|
Assert.Equal("Hello", msg.Parameters[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_WithClientOnlyTags_ExtractsPlusPrefix()
|
||||||
|
{
|
||||||
|
var msg = IrcMessage.Parse("@+reply=abc123;+example.com/tag=val PRIVMSG #channel :Hello");
|
||||||
|
|
||||||
|
Assert.Equal(2, msg.Tags.Count);
|
||||||
|
Assert.Equal("abc123", msg.Tags["+reply"]);
|
||||||
|
Assert.Equal("val", msg.Tags["+example.com/tag"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_WithTagsAndPrefix_ExtractsBoth()
|
||||||
|
{
|
||||||
|
var msg = IrcMessage.Parse("@time=2024-01-01T12:00:00.000Z :alice!user@host PRIVMSG #channel :Hello");
|
||||||
|
|
||||||
|
Assert.Single(msg.Tags);
|
||||||
|
Assert.Equal("alice!user@host", msg.Prefix);
|
||||||
|
Assert.Equal("PRIVMSG", msg.Command);
|
||||||
|
Assert.Equal("Hello", msg.Parameters[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_TagWithEscapedValue_Unescapes()
|
||||||
|
{
|
||||||
|
var msg = IrcMessage.Parse("@key=hello\\sworld\\:! PRIVMSG #channel :Hi");
|
||||||
|
|
||||||
|
Assert.Single(msg.Tags);
|
||||||
|
Assert.Equal("hello world;!", msg.Tags["key"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_TagWithNoValue_StoresNull()
|
||||||
|
{
|
||||||
|
var msg = IrcMessage.Parse("@empty;key=val PRIVMSG #channel :Hi");
|
||||||
|
|
||||||
|
Assert.Equal(2, msg.Tags.Count);
|
||||||
|
Assert.Null(msg.Tags["empty"]);
|
||||||
|
Assert.Equal("val", msg.Tags["key"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_BatchTag_ParsesMultilineContext()
|
||||||
|
{
|
||||||
|
var msg = IrcMessage.Parse("@batch=abc123 PRIVMSG #channel :line content");
|
||||||
|
|
||||||
|
Assert.Single(msg.Tags);
|
||||||
|
Assert.Equal("abc123", msg.Tags["batch"]);
|
||||||
|
Assert.Equal("PRIVMSG", msg.Command);
|
||||||
|
Assert.Equal("line content", msg.Parameters[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag Escaping ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagEscape_HandlesSpecialChars()
|
||||||
|
{
|
||||||
|
Assert.Equal("hello\\sworld", IrcMessage.TagEscape("hello world"));
|
||||||
|
Assert.Equal("a\\:b", IrcMessage.TagEscape("a;b"));
|
||||||
|
Assert.Equal("a\\\\b", IrcMessage.TagEscape("a\\b"));
|
||||||
|
Assert.Equal("a\\rb\\n", IrcMessage.TagEscape("a\rb\n"));
|
||||||
|
|
||||||
|
// Regular chars pass through unchanged
|
||||||
|
Assert.Equal("plain-text_123", IrcMessage.TagEscape("plain-text_123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagUnescape_HandlesEscapeSequences()
|
||||||
|
{
|
||||||
|
Assert.Equal("hello world", IrcMessage.TagUnescape("hello\\sworld"));
|
||||||
|
Assert.Equal("a;b", IrcMessage.TagUnescape("a\\:b"));
|
||||||
|
Assert.Equal("a\\b", IrcMessage.TagUnescape("a\\\\b"));
|
||||||
|
Assert.Equal("a\rb\n", IrcMessage.TagUnescape("a\\rb\\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BuildTagPrefix ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildTagPrefix_NoTags_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Equal("", IrcMessage.BuildTagPrefix());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildTagPrefix_SingleTag_FormatsCorrectly()
|
||||||
|
{
|
||||||
|
var result = IrcMessage.BuildTagPrefix(("time", "2024-01-01T12:00:00.000Z"));
|
||||||
|
Assert.Equal("@time=2024-01-01T12:00:00.000Z ", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildTagPrefix_MultipleTags_SemicolonSeparated()
|
||||||
|
{
|
||||||
|
var result = IrcMessage.BuildTagPrefix(("time", "2024-01-01T12:00:00.000Z"), ("msgid", "abc123"));
|
||||||
|
Assert.Equal("@time=2024-01-01T12:00:00.000Z;msgid=abc123 ", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildTagPrefix_TagWithNullValue_OmitsEquals()
|
||||||
|
{
|
||||||
|
var result = IrcMessage.BuildTagPrefix(("tag-only", null));
|
||||||
|
Assert.Equal("@tag-only ", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildTagPrefix_ClientOnlyTag_IncludesPlusPrefix()
|
||||||
|
{
|
||||||
|
var result = IrcMessage.BuildTagPrefix(("+reply", "msg-123"));
|
||||||
|
Assert.Equal("@+reply=msg-123 ", result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user