Add support for IRCv3 server-time, message-tags, echo-message, batch, draft/multiline extensions

This commit is contained in:
Stone_Red
2026-07-26 13:41:38 +02:00
parent b28b98e336
commit 11ce586f29
9 changed files with 966 additions and 40 deletions
@@ -335,6 +335,151 @@ public class IrcBroadcasterTests
// ── 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]
public async Task SendUserStatusChanged_IsNoOp()
{
+142 -2
View File
@@ -164,11 +164,59 @@ public class IrcCommandHandlerTests
// ── CAP / SASL ──────────────────────────────────────────────────────
[Fact]
public async Task CapLs_AdvertisesSasl()
public async Task CapLs_AdvertisesCapabilities()
{
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("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]
@@ -179,12 +227,61 @@ public class IrcCommandHandlerTests
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]
public async Task CapReqUnknown_GetsNak()
{
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]
@@ -693,6 +790,49 @@ public class IrcCommandHandlerTests
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 ──────────────────────────────────────────────────
[Fact]
+124
View File
@@ -180,4 +180,128 @@ public class IrcMessageTests
var msg = IrcMessage.Parse("MODE #channel +o alice");
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);
}
}