From 96736d69df39701b0c30da0f241f29e2a3272c9b Mon Sep 17 00:00:00 2001 From: HueByte Date: Fri, 17 Jul 2026 17:05:03 +0200 Subject: [PATCH] feat: enhance message broadcasting to exclude sender's connection and improve IRC compliance --- .../Contracts/IChatBroadcaster.cs | 9 ++++++- src/EchoHub.Core/Contracts/IChatService.cs | 6 +++-- src/EchoHub.Server.Irc/IrcBroadcaster.cs | 9 ++++--- src/EchoHub.Server.Irc/IrcCommandHandler.cs | 2 +- src/EchoHub.Server/Hubs/ChatHub.cs | 2 +- src/EchoHub.Server/Services/ChatService.cs | 4 +-- .../Services/SignalRBroadcaster.cs | 4 ++- src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs | 25 ++++++++++++++++--- src/EchoHub.Tests/Irc/TestHelpers.cs | 2 +- 9 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs index 884b587..70a6cf5 100644 --- a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs +++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs @@ -4,7 +4,14 @@ namespace EchoHub.Core.Contracts; public interface IChatBroadcaster { - Task SendMessageToChannelAsync(string channelName, MessageDto message); + /// + /// Broadcast a chat message to a channel. is the + /// connection the message originated from (IRC convention: never echo a message back to + /// the connection that sent it — its client already displayed it locally). Other + /// connections of the same user (e.g. an IRC session alongside a TUI session) still + /// receive the message. + /// + Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null); Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null); Task SendUserLeftAsync(string channelName, string username); Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null); diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index e3735af..072e4d1 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -13,8 +13,10 @@ public interface IChatService Task<(List History, string? Error, bool PasswordRequired)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName, string? password = null); Task LeaveChannelAsync(string connectionId, string username, string channelName); - // Messaging - Task SendMessageAsync(Guid userId, string username, string channelName, string content); + // Messaging. originConnectionId identifies the connection the message came from so + // broadcasters can avoid echoing it back to that one connection (IRC convention); + // the sender's other sessions still receive it. + Task SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null); Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0); // Presence diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index a5c2317..b1a5479 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -14,7 +14,7 @@ public class IrcBroadcaster : IChatBroadcaster _encryption = encryption; } - public async Task SendMessageToChannelAsync(string channelName, MessageDto message) + public async Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null) { // Decrypt transport-encrypted content for IRC clients (they can't handle // app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched. @@ -23,8 +23,11 @@ public class IrcBroadcaster : IChatBroadcaster foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) { - // IRC convention: don't echo sender's own message - if (conn.Nickname == message.SenderUsername) + // IRC convention: don't echo a message back to the connection that sent it + // (its client already displayed it locally). Match by connection id, not + // nickname — the same account may also be online via the TUI or a second + // IRC client, and those sessions must still receive the message. + if (conn.ConnectionId == excludeConnectionId) continue; foreach (var line in lines) diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 4745c90..54f1ec9 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -486,7 +486,7 @@ public sealed class IrcCommandHandler if (channelName is null) return; var error = await _chatService.SendMessageAsync( - _conn.UserId!.Value, _conn.Nickname!, channelName, content); + _conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId); if (error is not null) { diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index 8f0cc68..5af637c 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -103,7 +103,7 @@ public class ChatHub : Hub { try { - var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content); + var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content, Context.ConnectionId); if (error is not null) await Clients.Caller.Error(error); } diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 11004ef..eb471c1 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -151,7 +151,7 @@ public class ChatService : IChatService _logger.LogInformation("{User} left channel '{Channel}'", username, channelName); } - public async Task SendMessageAsync(Guid userId, string username, string channelName, string content) + public async Task SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null) { channelName = channelName.ToLowerInvariant().Trim(); @@ -240,7 +240,7 @@ public class ChatService : IChatService Embeds: embeds, SenderDisplayName: sender?.DisplayName); - await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); + await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto, originConnectionId)); _logger.LogDebug("{User} sent message in '{Channel}'", username, channelName); return null; diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs index 7e4165d..f630f23 100644 --- a/src/EchoHub.Server/Services/SignalRBroadcaster.cs +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -20,7 +20,9 @@ public class SignalRBroadcaster : IChatBroadcaster _presenceTracker = presenceTracker; } - public Task SendMessageToChannelAsync(string channelName, MessageDto message) + // The exclusion only applies to the IRC gateway (SignalR clients render their own + // message from the broadcast echo), so the id is ignored here. + public Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null) => HubContext.Clients.Group(channelName).ReceiveMessage(message); public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null) diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs index c148dfc..a7c25b4 100644 --- a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs @@ -111,23 +111,40 @@ public class IrcBroadcasterTests } [Fact] - public async Task SendMessage_SkipsSender() + public async Task SendMessage_SkipsOnlyOriginConnection() { - var (_, aliceStream) = AddConnectionWithCapture("alice", "general"); + var (aliceConn, aliceStream) = AddConnectionWithCapture("alice", "general"); var (_, bobStream) = AddConnectionWithCapture("bob", "general"); var message = new MessageDto( Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow); - await _broadcaster.SendMessageToChannelAsync("general", message); + await _broadcaster.SendMessageToChannelAsync("general", message, aliceConn.ConnectionId); - // Alice (sender) should NOT receive the message + // The connection that sent it should NOT get an echo Assert.Empty(aliceStream.GetOutputLines()); // Bob should receive it Assert.NotEmpty(bobStream.GetOutputLines()); } + [Fact] + public async Task SendMessage_SendersOtherSessionsStillReceive() + { + // Same account online twice (e.g. TUI + IRC, or two IRC clients): a message sent + // from one session must still reach the other — skipping by nickname used to + // swallow these until the IRC client reconnected. + var (_, ircStream) = AddConnectionWithCapture("alice", "general"); + + var message = new MessageDto( + Guid.NewGuid(), _encryption.Encrypt("sent from the TUI"), "alice", null, "general", DateTimeOffset.UtcNow); + + // Origin is a SignalR connection, not this IRC one + await _broadcaster.SendMessageToChannelAsync("general", message, "signalr-conn-123"); + + Assert.Contains(ircStream.GetOutputLines(), l => l.Contains("sent from the TUI")); + } + [Fact] public async Task SendMessage_OnlySendsToChannelMembers() { diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 760c47e..fea18b6 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -188,7 +188,7 @@ internal sealed class FakeChatService : IChatService return Task.CompletedTask; } - public Task SendMessageAsync(Guid userId, string username, string channelName, string content) + public Task SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null) { SentMessages.Add((channelName, content)); return Task.FromResult(SendMessageError);