From 5d1934e2c69af02a156936ad27bfa6104dd8cd6e Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:30:23 +0200 Subject: [PATCH] fix: IRCv3 echo messages incorrect format --- src/EchoHub.Server.Irc/IrcBroadcaster.cs | 55 +++++++++++++++---- src/EchoHub.Server.Irc/IrcCommandHandler.cs | 31 +++++++++-- .../MultilineBatchContext.cs | 1 + 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 015f47c..ac191b5 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -23,7 +23,16 @@ public class IrcBroadcaster : IChatBroadcaster Content = _encryption.Decrypt(message.Content), ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null, }; - var baseLines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl); + + // Clients that support message-tags receive the +reply tag instead of + // the text reply prefix, so format without it. The sender's echo-message + // also needs the raw content to correlate with what they sent. + var hasReply = message.ReplyTo is not null; + var modernMessage = hasReply ? decryptedMessage with { ReplyTo = null } : decryptedMessage; + var legacyLines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl); + var modernLines = hasReply + ? IrcMessageFormatter.FormatMessage(modernMessage, _gateway.Options.PublicBaseUrl) + : legacyLines; // Compute shared tag components (same for all connections in this channel) var serverTimeTag = message.SentAt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); @@ -37,6 +46,11 @@ public class IrcBroadcaster : IChatBroadcaster if (conn.ConnectionId == excludeConnectionId && !conn.HasCap("echo-message")) continue; + // Use modern lines (without reply prefix) for echo-message senders and + // for any client that gets the +reply tag via message-tags capability. + var useModern = conn.ConnectionId == excludeConnectionId || conn.HasCap("message-tags"); + var lines = useModern ? modernLines : legacyLines; + // Build per-connection tags var tags = new List<(string Key, string? Value)>(); @@ -52,13 +66,13 @@ public class IrcBroadcaster : IChatBroadcaster var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : ""; - if (conn.HasCap("draft/multiline") && conn.HasCap("batch") && baseLines.Count > 1) + if (conn.HasCap("draft/multiline") && conn.HasCap("batch") && lines.Count > 1) { - await SendMultilineBatchAsync(conn, channelName, tagPrefix, baseLines); + await SendMultilineBatchAsync(conn, channelName, tagPrefix, lines); } else { - foreach (var line in baseLines) + foreach (var line in lines) await conn.SendAsync(tagPrefix + line); } } @@ -72,10 +86,32 @@ public class IrcBroadcaster : IChatBroadcaster 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} "; + // Per draft/multiline spec: msgid and +reply go on the BATCH start line + // only; per-message tags (time, batch) go on individual lines. + // Parse the pre-built tagPrefix to split batch-level from line-level tags. + string batchTags, lineTags; + if (tagPrefix.Length > 0 && tagPrefix.StartsWith('@')) + { + var tagBody = tagPrefix.AsSpan(1).TrimEnd(' '); + var parts = tagBody.ToString().Split(';', StringSplitOptions.RemoveEmptyEntries); + var batchParts = new List(); + var lineParts = new List(); + foreach (var part in parts) + { + if (part.StartsWith("msgid=") || part.StartsWith("+reply=")) + batchParts.Add(part); + else + lineParts.Add(part); + } + batchTags = batchParts.Count > 0 ? "@" + string.Join(";", batchParts) + " " : ""; + lineParts.Add("batch=" + batchRef); + lineTags = "@" + string.Join(";", lineParts) + " "; + } + else + { + batchTags = ""; + lineTags = "@batch=" + batchRef + " "; + } // Extract the sender prefix from the first line var firstLine = lines[0]; @@ -83,11 +119,10 @@ public class IrcBroadcaster : IChatBroadcaster ? 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($"{lineTags}{line}"); await conn.SendAsync($"BATCH -{batchRef}"); } diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index c1f7828..d4c1cf2 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -346,7 +346,16 @@ public sealed class IrcCommandHandler var channelName = IrcToEchoHubChannel(target); if (channelName is null) return; - _conn.PendingMultilineBatch = new MultilineBatchContext(reference[1..], channelName); + var batchCtx = new MultilineBatchContext(reference[1..], channelName); + + // Capture +reply tag from the BATCH start line for reply handling + if (msg.Tags.TryGetValue("+reply", out var replyStr) && + Guid.TryParse(replyStr, out var replyId)) + { + batchCtx.ReplyToMessageId = replyId; + } + + _conn.PendingMultilineBatch = batchCtx; } } @@ -371,8 +380,14 @@ public sealed class IrcCommandHandler // 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); + var error = await _chatService.SendMessageAsync( + _conn.UserId!.Value, _conn.Nickname!, batch.Target, content, _conn.ConnectionId, batch.ReplyToMessageId); + + if (error is not null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CANNOTSENDTOCHAN, + $"#{batch.Target} :{error}"); + } } // ── Authentication ────────────────────────────────────────────────────── @@ -789,8 +804,16 @@ public sealed class IrcCommandHandler return; } + // Parse +reply tag for reply-to support from IRC clients + Guid? replyTo = null; + if (msg.Tags.TryGetValue("+reply", out var replyStr) && + Guid.TryParse(replyStr, out var replyId)) + { + replyTo = replyId; + } + var error = await _chatService.SendMessageAsync( - _conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId); + _conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId, replyTo); if (error is not null) { diff --git a/src/EchoHub.Server.Irc/MultilineBatchContext.cs b/src/EchoHub.Server.Irc/MultilineBatchContext.cs index 9b5e915..2cfd488 100644 --- a/src/EchoHub.Server.Irc/MultilineBatchContext.cs +++ b/src/EchoHub.Server.Irc/MultilineBatchContext.cs @@ -10,4 +10,5 @@ public sealed class MultilineBatchContext(string referenceTag, string target) public string Target { get; } = target; public List Lines { get; } = []; public bool UsesConcat { get; set; } + public Guid? ReplyToMessageId { get; set; } }