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);