feat: enhance message broadcasting to exclude sender's connection and improve IRC compliance

This commit is contained in:
HueByte
2026-07-17 17:05:03 +02:00
parent 953e081123
commit 96736d69df
9 changed files with 47 additions and 16 deletions
@@ -4,7 +4,14 @@ namespace EchoHub.Core.Contracts;
public interface IChatBroadcaster public interface IChatBroadcaster
{ {
Task SendMessageToChannelAsync(string channelName, MessageDto message); /// <summary>
/// Broadcast a chat message to a channel. <paramref name="excludeConnectionId"/> 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.
/// </summary>
Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null);
Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null); Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null);
Task SendUserLeftAsync(string channelName, string username); Task SendUserLeftAsync(string channelName, string username);
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null); Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
+4 -2
View File
@@ -13,8 +13,10 @@ public interface IChatService
Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName, string? password = null); Task<(List<MessageDto> 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); Task LeaveChannelAsync(string connectionId, string username, string channelName);
// Messaging // Messaging. originConnectionId identifies the connection the message came from so
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content); // broadcasters can avoid echoing it back to that one connection (IRC convention);
// the sender's other sessions still receive it.
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null);
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0); Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
// Presence // Presence
+6 -3
View File
@@ -14,7 +14,7 @@ public class IrcBroadcaster : IChatBroadcaster
_encryption = encryption; _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 // Decrypt transport-encrypted content for IRC clients (they can't handle
// app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched. // 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)) foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{ {
// IRC convention: don't echo sender's own message // IRC convention: don't echo a message back to the connection that sent it
if (conn.Nickname == message.SenderUsername) // (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; continue;
foreach (var line in lines) foreach (var line in lines)
+1 -1
View File
@@ -486,7 +486,7 @@ public sealed class IrcCommandHandler
if (channelName is null) return; if (channelName is null) return;
var error = await _chatService.SendMessageAsync( 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) if (error is not null)
{ {
+1 -1
View File
@@ -103,7 +103,7 @@ public class ChatHub : Hub<IEchoHubClient>
{ {
try 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) if (error is not null)
await Clients.Caller.Error(error); await Clients.Caller.Error(error);
} }
+2 -2
View File
@@ -151,7 +151,7 @@ public class ChatService : IChatService
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName); _logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
} }
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content) public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null)
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
@@ -240,7 +240,7 @@ public class ChatService : IChatService
Embeds: embeds, Embeds: embeds,
SenderDisplayName: sender?.DisplayName); 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); _logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
return null; return null;
@@ -20,7 +20,9 @@ public class SignalRBroadcaster : IChatBroadcaster
_presenceTracker = presenceTracker; _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); => HubContext.Clients.Group(channelName).ReceiveMessage(message);
public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null) public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
+21 -4
View File
@@ -111,23 +111,40 @@ public class IrcBroadcasterTests
} }
[Fact] [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 (_, bobStream) = AddConnectionWithCapture("bob", "general");
var message = new MessageDto( var message = new MessageDto(
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow); 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()); Assert.Empty(aliceStream.GetOutputLines());
// Bob should receive it // Bob should receive it
Assert.NotEmpty(bobStream.GetOutputLines()); 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] [Fact]
public async Task SendMessage_OnlySendsToChannelMembers() public async Task SendMessage_OnlySendsToChannelMembers()
{ {
+1 -1
View File
@@ -188,7 +188,7 @@ internal sealed class FakeChatService : IChatService
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content) public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null)
{ {
SentMessages.Add((channelName, content)); SentMessages.Add((channelName, content));
return Task.FromResult(SendMessageError); return Task.FromResult(SendMessageError);