mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: enhance message broadcasting to exclude sender's connection and improve IRC compliance
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user