1 Commits
Author SHA1 Message Date
Hue 2d5f8eeded Merge pull request #73 from HueByte/dev
Dev
2026-07-23 23:07:58 +02:00
18 changed files with 41 additions and 1118 deletions
-42
View File
@@ -1,42 +0,0 @@
# Drift Report — HueByte/EchoHub
> no docs evaluated since commit `2d5f8ee`.
**Drift score:** n/a (no docs evaluated)
| Status | Count |
| --- | ---: |
| 🔴 Broken | 0 |
| 🟡 Stale | 0 |
| 🟠 Ambiguous | 0 |
| 🟢 OK | 0 |
| **Total** | **0** |
## Broken (0)
Docs that reference symbols the merge deleted. These must be regenerated or removed before publish.
*None.*
## Stale (0)
Docs whose underlying symbols changed in this merge. Targeted regeneration is in flight.
*None.*
## Ambiguous (0)
Docs that reference symbols the baseline cache doesn't know about — likely a stale cache rather than real drift.
*None.*
## OK (0)
Docs the merge did not impact. No action required.
*None.*
---
*Generated by AurionDocs on 2026-07-25 19:24:11 UTC*
*Commit range: `2d5f8ee` → `65766ea`*
-1
View File
@@ -4,7 +4,6 @@ Release history for EchoHub.
## Releases
- [v0.2.18](v0.2.18.md) - IRCv3 Capabilities & Native OS Notifications
- [v0.2.17](v0.2.17.md) - Server Version Reporting & IRC Multi-Line Fix
- [v0.2.16](v0.2.16.md) - Periodic Server-Stats Report, Upload & Moderation Logging & Quieter Connection Logs
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
-2
View File
@@ -1,7 +1,5 @@
- name: Overview
href: index.md
- name: v0.2.18
href: v0.2.18.md
- name: v0.2.17
href: v0.2.17.md
- name: v0.2.16
-6
View File
@@ -1,6 +0,0 @@
# v0.2.18
## New Features
- **IRCv3 capabilities** — `server-time`, `message-tags`, `echo-message`, `batch` and `draft/multiline` are now advertised and fully supported in the IRC gateway. CAP LS 302 requests receive capability values. Clients can enable caps via CAP REQ and disable them with CAP REQ -cap.
- **Native OS notifications** — EchoHub now supports native notifications on Windows, macOS and Linux. Notifications are sent for mentions or replies.
-1
View File
@@ -23,4 +23,3 @@
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
- [x] space between mod|admin "icon" and username
- [ ] Embeds still incorrectly display colors
- [ ] Find a better solution for Native OS notifications, currently uses the [OsNotifications](https://github.com/DemonExposer/OsNotifications) library, maintained by a single person.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.2.18</Version>
<Version>0.2.17</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
-5
View File
@@ -27,7 +27,6 @@ public sealed class AppOrchestrator : IDisposable
private readonly ChatMessageManager _messageManager;
private readonly CommandHandler _commandHandler;
private readonly NotificationSoundService _notificationSound;
private readonly OsNotificationService _osNotification;
private readonly AudioPlaybackService _audioPlayback = new();
private readonly UpdateChecker _updateService;
private readonly ConnectionManager _conn = new();
@@ -66,7 +65,6 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow = new MainWindow(app, _messageManager);
_commandHandler = new CommandHandler();
_notificationSound = new NotificationSoundService(config.Notifications);
_osNotification = new OsNotificationService();
_updateService = new UpdateChecker(app);
WireMainWindowEvents();
@@ -1107,9 +1105,6 @@ public sealed class AppOrchestrator : IDisposable
&& message.Content.Contains($"@{_session.Username}", StringComparison.OrdinalIgnoreCase))
{
_ = _notificationSound.PlayAsync();
string title = $"Mentioned by {message.SenderDisplayName ?? message.SenderUsername} in #{message.ChannelName}";
string body = message.Content.Length > 200 ? message.Content[..200] + "..." : message.Content;
_osNotification.Show(title, body);
}
};
-1
View File
@@ -9,7 +9,6 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
<PackageReference Include="OsNotifications" Version="1.1.5" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
@@ -1,34 +0,0 @@
using OsNotifications;
using Serilog;
namespace EchoHub.Client.Services;
public sealed class OsNotificationService
{
static OsNotificationService()
{
Notifications.SetGuiApplication(true);
}
/// <summary>
/// Shows an OS notification with the given title and optional body.
/// </summary>
/// <param name="title"></param>
/// <param name="body"></param>
public void Show(string title, string? body = null)
{
try
{
Notifications.ShowNotification(title, body ?? string.Empty);
}
catch (PlatformNotSupportedException ex)
{
Log.Warning(ex, "OS notifications not supported on this platform");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to show OS notification");
}
}
}
+11 -120
View File
@@ -23,123 +23,28 @@ public class IrcBroadcaster : IChatBroadcaster
Content = _encryption.Decrypt(message.Content),
ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
};
// 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");
var msgid = message.Id.ToString("D");
var replyMsgid = message.ReplyTo?.MessageId.ToString("D");
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
// Don't echo back to traditional IRC clients (they display locally).
// Echo-message clients need their own messages back for msgid/+reply tracking.
if (conn.ConnectionId == excludeConnectionId && !conn.HasCap("echo-message"))
// 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;
// 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)>();
if (conn.HasCap("server-time"))
tags.Add(("time", serverTimeTag));
if (conn.HasCap("message-tags"))
{
tags.Add(("msgid", msgid));
if (replyMsgid is not null)
tags.Add(("+reply", replyMsgid));
}
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
if (conn.HasCap("draft/multiline") && conn.HasCap("batch") && lines.Count > 1)
{
await SendMultilineBatchAsync(conn, channelName, tagPrefix, lines);
}
else
{
foreach (var line in lines)
await conn.SendAsync(tagPrefix + line);
}
foreach (var line in lines)
await conn.SendAsync(line);
}
}
private async Task SendMultilineBatchAsync(
IrcClientConnection conn, string channelName, string tagPrefix, List<string> lines)
{
if (lines.Count == 0) return;
var batchRef = $"ml{Guid.NewGuid().ToString("N")[..8]}";
var ircChannel = $"#{channelName}";
// 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<string>();
var lineParts = new List<string>();
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];
var senderPrefix = firstLine.StartsWith(':')
? firstLine[1..firstLine.IndexOf(' ')]
: _gateway.Options.ServerName;
await conn.SendAsync($"{batchTags}:{senderPrefix} BATCH +{batchRef} draft/multiline {ircChannel}");
foreach (var line in lines)
await conn.SendAsync($"{lineTags}{line}");
await conn.SendAsync($"BATCH -{batchRef}");
}
public async Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
{
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
if (conn.ConnectionId == excludeConnectionId) continue;
var line = $":{username}!{username}@echohub JOIN #{channelName}";
var tags = new List<(string Key, string? Value)>();
if (conn.HasCap("server-time"))
tags.Add(("time", DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
await conn.SendAsync(tagPrefix + line);
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
}
}
@@ -148,14 +53,7 @@ public class IrcBroadcaster : IChatBroadcaster
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
if (conn.Nickname == username) continue;
var line = $":{username}!{username}@echohub PART #{channelName}";
var tags = new List<(string Key, string? Value)>();
if (conn.HasCap("server-time"))
tags.Add(("time", DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
await conn.SendAsync(tagPrefix + line);
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
}
}
@@ -166,14 +64,7 @@ public class IrcBroadcaster : IChatBroadcaster
foreach (var conn in _gateway.GetConnectionsInChannel(target))
{
var line = $":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}";
var tags = new List<(string Key, string? Value)>();
if (conn.HasCap("server-time"))
tags.Add(("time", DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
await conn.SendAsync(tagPrefix + line);
await conn.SendAsync($":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
}
}
@@ -28,22 +28,10 @@ public sealed class IrcClientConnection : IAsyncDisposable
public bool IsSasl { get; set; }
public bool CapNegotiating { get; set; }
// Highest CAP LS version received from client (0 = no version)
public int CapVersion { get; set; }
public HashSet<string> EnabledCaps { get; } = new(StringComparer.OrdinalIgnoreCase);
public bool HasCap(string cap) => EnabledCaps.Contains(cap);
public void EnableCap(string cap) => EnabledCaps.Add(cap);
public void DisableCap(string cap) => EnabledCaps.Remove(cap);
// Channel state — thread-safe: written by command handler, read by broadcaster threads
private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
private readonly object _channelLock = new();
// Non-null while BATCH lines are being collected
public MultilineBatchContext? PendingMultilineBatch { get; set; }
// Away state
public string? AwayMessage { get; set; }
+19 -352
View File
@@ -19,16 +19,6 @@ public sealed class IrcCommandHandler
private string ServerName => _options.ServerName;
private static readonly Dictionary<string, string?> ServerCaps = new(StringComparer.OrdinalIgnoreCase)
{
["sasl"] = null,
["server-time"] = null,
["message-tags"] = null,
["echo-message"] = null,
["batch"] = null,
["draft/multiline"] = "max-bytes=40000,max-lines=10",
};
public IrcCommandHandler(
IrcClientConnection conn,
IrcOptions options,
@@ -92,7 +82,6 @@ public sealed class IrcCommandHandler
"JOIN" => HandleJoinAsync(msg),
"PART" => HandlePartAsync(msg),
"PRIVMSG" => HandlePrivmsgAsync(msg),
"NOTICE" => Task.CompletedTask,
"QUIT" => HandleQuitAsync(msg),
"NAMES" => HandleNamesAsync(msg),
"TOPIC" => HandleTopicAsync(msg),
@@ -104,46 +93,36 @@ public sealed class IrcCommandHandler
"MOTD" => SendMotdAsync(),
"USERHOST" or "LUSERS" => Task.CompletedTask,
// IRCv3 multiline batch
"BATCH" => HandleBatchAsync(msg),
_ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND,
$"{command} :Unknown command"),
};
}
// ── IRCv3 CAP Negotiation ─────────────────────────────────────────────
private static readonly string[] CapList302 = [.. ServerCaps.Select(kvp =>
kvp.Value is not null ? $"{kvp.Key}={kvp.Value}" : kvp.Key)];
private static readonly string[] CapListLegacy = [.. ServerCaps.Keys];
// ── Authentication ──────────────────────────────────────────────────────
private async Task HandleCapAsync(IrcMessage msg)
{
if (msg.Parameters.Count < 1)
{
await SendInvalidCapCmdAsync("CAP requires a subcommand");
return;
}
if (msg.Parameters.Count < 1) return;
var subcommand = msg.Parameters[0].ToUpperInvariant();
// Build the nick placeholder for server responses (use * while unregistered)
var nick = _conn.Nickname ?? "*";
switch (subcommand)
switch (msg.Parameters[0].ToUpperInvariant())
{
case "LS":
await HandleCapLsAsync(msg, nick);
break;
case "LIST":
await HandleCapListAsync(nick);
await _conn.SendAsync($":{ServerName} CAP * LS :sasl");
_conn.CapNegotiating = true;
break;
case "REQ":
await HandleCapReqAsync(msg, nick);
if (msg.Parameters.Count >= 2 &&
msg.Parameters[1].Trim().Equals("sasl", StringComparison.OrdinalIgnoreCase))
{
await _conn.SendAsync($":{ServerName} CAP * ACK :sasl");
_conn.IsSasl = true;
}
else
{
var requested = msg.Parameters.ElementAtOrDefault(1) ?? "";
await _conn.SendAsync($":{ServerName} CAP * NAK :{requested}");
}
break;
case "END":
@@ -151,247 +130,9 @@ public sealed class IrcCommandHandler
if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered)
await TryCompleteRegistrationAsync();
break;
default:
await SendInvalidCapCmdAsync($"Unknown subcommand {subcommand}");
break;
}
}
private async Task HandleCapLsAsync(IrcMessage msg, string nick)
{
// Parse optional version argument
var version = 0;
if (msg.Parameters.Count >= 2 && int.TryParse(msg.Parameters[1], out var v))
version = v;
// Store the highest version seen (clients cannot downgrade)
if (version > _conn.CapVersion)
_conn.CapVersion = version;
// Decide capability list format based on negotiated version
var caps = version >= 302 ? CapList302 : CapListLegacy;
// Suspend registration during CAP negotiation
_conn.CapNegotiating = true;
// Multiline CAP LS 302 response
if (version >= 302 && caps.Length > 0)
{
// If the total fits in one line, send it as a single reply
var singleLine = string.Join(" ", caps);
if (singleLine.Length < 400)
{
await _conn.SendAsync($":{ServerName} CAP {nick} LS :{singleLine}");
}
else
{
// Split across multiple lines; all but the last get '*' as a marker
var lines = SplitCapList(caps, 400);
for (var i = 0; i < lines.Count; i++)
{
var marker = i < lines.Count - 1 ? "*" : "";
await _conn.SendAsync($":{ServerName} CAP {nick} LS {marker}:{lines[i]}");
}
}
}
else if (caps.Length > 0)
{
await _conn.SendAsync($":{ServerName} CAP {nick} LS :{string.Join(" ", caps)}");
}
else
{
await _conn.SendAsync($":{ServerName} CAP {nick} LS :");
}
}
private async Task HandleCapListAsync(string nick)
{
var enabled = _conn.EnabledCaps.ToArray();
if (enabled.Length > 0)
{
await _conn.SendAsync($":{ServerName} CAP {nick} LIST :{string.Join(" ", enabled)}");
}
else
{
await _conn.SendAsync($":{ServerName} CAP {nick} LIST :");
}
}
private async Task HandleCapReqAsync(IrcMessage msg, string nick)
{
if (msg.Parameters.Count < 2 || string.IsNullOrWhiteSpace(msg.Parameters[1]))
{
await _conn.SendAsync($":{ServerName} CAP {nick} NAK :");
return;
}
var requested = msg.Parameters[1].Split(' ', StringSplitOptions.RemoveEmptyEntries);
// Validate all caps are known before modifying anything (all-or-nothing)
var ackList = new List<string>();
var valid = true;
foreach (var item in requested)
{
var cap = item;
if (cap.StartsWith('-'))
cap = cap[1..];
// cap-notify is implicitly enabled for CAP LS 302; accept it silently
if (cap.Equals("cap-notify", StringComparison.OrdinalIgnoreCase))
continue;
if (!ServerCaps.ContainsKey(cap))
{
valid = false;
break;
}
}
if (!valid)
{
await _conn.SendAsync($":{ServerName} CAP {nick} NAK :{msg.Parameters[1]}");
return;
}
foreach (var item in requested)
{
if (item.StartsWith('-'))
{
_conn.DisableCap(item[1..]);
ackList.Add(item);
}
else
{
_conn.EnableCap(item);
ackList.Add(item);
}
}
if (ackList.Count > 0)
{
await _conn.SendAsync($":{ServerName} CAP {nick} ACK :{string.Join(" ", ackList)}");
}
else
{
// No actual caps to ack (e.g. cap-notify only) — send empty ACK
await _conn.SendAsync($":{ServerName} CAP {nick} ACK :");
}
}
private async Task SendInvalidCapCmdAsync(string message)
{
var nick = _conn.Nickname ?? "*";
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_INVALIDCAPCMD, nick, $"{message}");
}
private static List<string> SplitCapList(string[] caps, int maxLen)
{
var lines = new List<string>();
var current = new List<string>();
var currentLen = 0;
foreach (var cap in caps)
{
var capLen = cap.Length + (current.Count > 0 ? 1 : 0); // +1 for leading space
if (currentLen + capLen > maxLen && current.Count > 0)
{
lines.Add(string.Join(" ", current));
current.Clear();
currentLen = 0;
capLen = cap.Length;
}
current.Add(cap);
currentLen += capLen;
}
if (current.Count > 0)
lines.Add(string.Join(" ", current));
return lines.Count > 0 ? lines : [""];
}
// ── IRCv3 Multiline Batch ─────────────────────────────────────────────
private async Task HandleBatchAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var reference = msg.Parameters[0];
if (reference.StartsWith('-'))
{
// BATCH -ref → end of batch
var batch = _conn.PendingMultilineBatch;
if (batch is null || batch.ReferenceTag != reference[1..])
return;
_conn.PendingMultilineBatch = null;
await FlushMultilineBatchAsync(batch);
}
else
{
// BATCH +ref type [target]
if (msg.Parameters.Count < 2) return;
var type = msg.Parameters[1];
if (!type.Equals("draft/multiline", StringComparison.OrdinalIgnoreCase))
return;
if (msg.Parameters.Count < 3) return;
var target = msg.Parameters[2];
var channelName = IrcToEchoHubChannel(target);
if (channelName is null) return;
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;
}
}
private async Task FlushMultilineBatchAsync(MultilineBatchContext batch)
{
if (batch.Lines.Count == 0) return;
// Validate: no blank lines with concat tag, no entirely blank messages
var allBlank = true;
for (var i = 0; i < batch.Lines.Count; i++)
{
if (!string.IsNullOrWhiteSpace(batch.Lines[i]))
{
allBlank = false;
break;
}
}
if (allBlank) return;
// Per spec: lines joined by \n by default; draft/multiline-concat lines
// are directly concatenated (already handled during collection).
var content = string.Join("\n", batch.Lines);
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 ──────────────────────────────────────────────────────
private async Task HandleAuthenticateAsync(IrcMessage msg)
{
if (msg.Parameters.Count < 1) return;
@@ -587,26 +328,8 @@ public sealed class IrcCommandHandler
$":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
$"{ServerName} EchoHub-IRC o o");
var isupportTokens = new List<string>
{
"CHANTYPES=#",
"CHANMODES=b,k,,,",
"NICKLEN=50",
"CHANNELLEN=100",
"CLIENTTAGDENY=*,-reply",
};
// If the client has message-tags, advertise CLIENTTAGDENY
if (_conn.HasCap("message-tags"))
{
isupportTokens.Add("CLIENTTAGDENY=*,-reply");
}
isupportTokens.Add(":are supported by this server");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
string.Join(" ", isupportTokens));
"CHANTYPES=# CHANMODES=b,k,, NICKLEN=50 CHANNELLEN=100 :are supported by this server");
await SendMotdAsync();
}
@@ -726,7 +449,7 @@ public sealed class IrcCommandHandler
};
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
foreach (var line in lines)
await _conn.SendAsync(DecorateLineForConnection(line, m));
await _conn.SendAsync(line);
}
}
}
@@ -776,44 +499,8 @@ public sealed class IrcCommandHandler
var channelName = IrcToEchoHubChannel(target);
if (channelName is null) return;
// Check if we're inside a multiline batch
var batch = _conn.PendingMultilineBatch;
if (batch is not null)
{
if (batch.Target != channelName)
return;
// Collect this line. If the message has the draft/multiline-concat tag,
// it appends directly without a newline separator.
var isConcat = msg.Tags.ContainsKey("draft/multiline-concat");
if (isConcat)
{
if (string.IsNullOrEmpty(content))
return; // blank concat lines not allowed
batch.UsesConcat = true;
// Append to the last line (or start a new one)
if (batch.Lines.Count > 0)
batch.Lines[^1] += content;
else
batch.Lines.Add(content);
}
else
{
batch.Lines.Add(content);
}
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, replyTo);
_conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId);
if (error is not null)
{
@@ -1129,24 +816,4 @@ public sealed class IrcCommandHandler
var name = ircChannel[1..].ToLowerInvariant().Trim();
return ValidationConstants.ChannelNameRegex().IsMatch(name) ? name : null;
}
private string DecorateLineForConnection(string line, MessageDto message)
{
var tags = new List<(string Key, string? Value)>();
if (_conn.HasCap("server-time"))
tags.Add(("time", message.SentAt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
if (_conn.HasCap("message-tags"))
{
tags.Add(("msgid", message.Id.ToString("D")));
if (message.ReplyTo is not null)
tags.Add(("+reply", message.ReplyTo.MessageId.ToString("D")));
}
if (tags.Count == 0)
return line;
return IrcMessage.BuildTagPrefix([.. tags]) + line;
}
}
+8 -115
View File
@@ -1,14 +1,11 @@
using System.Text;
namespace EchoHub.Server.Irc;
/// <summary>
/// Parsed representation of an IRC protocol line.
/// IRCv3 format: ['@' tags ' '] [':' prefix ' '] COMMAND [params...] [':' trailing]
/// Format: [:prefix] COMMAND [params...] [:trailing]
/// </summary>
public sealed class IrcMessage
{
public Dictionary<string, string?> Tags { get; init; } = new();
public string? Prefix { get; init; }
public string Command { get; init; } = "";
public List<string> Parameters { get; init; } = [];
@@ -16,64 +13,29 @@ public sealed class IrcMessage
public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
/// <summary>
/// Parse a raw IRC line with optional IRCv3 message tags.
/// Format: ['@' tags ' '] [':' prefix ' '] COMMAND params CRLF
/// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF
/// </summary>
public static IrcMessage Parse(string line)
{
var span = line.AsSpan().TrimEnd("\r\n");
string? prefix = null;
var pos = 0;
if (span.Length == 0)
return new IrcMessage { Command = "" };
var tags = new Dictionary<string, string?>();
// Parse optional IRCv3 message tags: @tag1;tag2=val2;...
if (span[pos] == '@')
// Parse optional prefix
if (span.Length > 0 && span[0] == ':')
{
var spaceIdx = span.IndexOf(' ');
if (spaceIdx > 1)
{
var tagSection = span[1..spaceIdx].ToString();
foreach (var rawTag in tagSection.Split(';'))
{
var eqIdx = rawTag.IndexOf('=');
if (eqIdx == -1)
{
tags[rawTag] = null;
}
else
{
var key = rawTag[..eqIdx];
var val = TagUnescape(rawTag[(eqIdx + 1)..]);
tags[key] = val;
}
}
pos = spaceIdx + 1;
}
}
string? prefix = null;
// Parse optional prefix
if (pos < span.Length && span[pos] == ':')
{
var spaceIdx = span[pos..].IndexOf(' ');
if (spaceIdx == -1)
return new IrcMessage { Tags = tags, Prefix = span[(pos + 1)..].ToString(), Command = "" };
return new IrcMessage { Prefix = span[1..].ToString() };
prefix = span.Slice(pos + 1, spaceIdx - 1).ToString();
pos = pos + spaceIdx + 1;
prefix = span[1..spaceIdx].ToString();
pos = spaceIdx + 1;
}
// Skip whitespace
while (pos < span.Length && span[pos] == ' ') pos++;
// Parse command
if (pos >= span.Length)
return new IrcMessage { Tags = tags, Prefix = prefix, Command = "" };
var cmdStart = pos;
while (pos < span.Length && span[pos] != ' ') pos++;
var command = span[cmdStart..pos].ToString();
@@ -99,78 +61,9 @@ public sealed class IrcMessage
return new IrcMessage
{
Tags = tags,
Prefix = prefix,
Command = command,
Parameters = parameters,
};
}
/// <summary>
/// Builds the message tags prefix for outgoing lines.
/// Returns "@key1=val1;key2=val2 " or empty string if no tags.
/// Values are tag-escaped. Client-only tags (+prefix) pass through.
/// </summary>
public static string BuildTagPrefix(params (string Key, string? Value)[] tags)
{
if (tags.Length == 0) return "";
var sb = new StringBuilder("@");
var first = true;
foreach (var (key, value) in tags)
{
if (!first) sb.Append(';');
first = false;
sb.Append(key);
if (value is not null)
{
sb.Append('=');
sb.Append(TagEscape(value));
}
}
sb.Append(' ');
return sb.ToString();
}
/// <summary>
/// Escape a tag value per IRCv3 message-tags spec.
/// ; → \:, SPACE → \s, \ → \\, CR → \r, LF → \n
/// </summary>
public static string TagEscape(string value)
{
return value
.Replace("\\", "\\\\")
.Replace(";", "\\:")
.Replace(" ", "\\s")
.Replace("\r", "\\r")
.Replace("\n", "\\n");
}
/// <summary>
/// Unescape a tag value per IRCv3 message-tags spec.
/// </summary>
public static string TagUnescape(string value)
{
var sb = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] == '\\' && i + 1 < value.Length)
{
switch (value[i + 1])
{
case ':': sb.Append(';'); i++; break;
case 's': sb.Append(' '); i++; break;
case '\\': sb.Append('\\'); i++; break;
case 'r': sb.Append('\r'); i++; break;
case 'n': sb.Append('\n'); i++; break;
default: sb.Append(value[i]); break;
}
}
else
{
sb.Append(value[i]);
}
}
return sb.ToString();
}
}
@@ -45,7 +45,6 @@ public static class IrcNumericReply
public const string RPL_ENDOFBANLIST = "368";
// Errors
public const string ERR_INVALIDCAPCMD = "410";
public const string ERR_NOSUCHNICK = "401";
public const string ERR_NOSUCHCHANNEL = "403";
public const string ERR_CANNOTSENDTOCHAN = "404";
@@ -1,14 +0,0 @@
namespace EchoHub.Server.Irc;
/// <summary>
/// Tracks an in-progress multiline batch (draft/multiline) being collected
/// from an IRC client before dispatching as a single EchoHub message.
/// </summary>
public sealed class MultilineBatchContext(string referenceTag, string target)
{
public string ReferenceTag { get; } = referenceTag;
public string Target { get; } = target;
public List<string> Lines { get; } = [];
public bool UsesConcat { get; set; }
public Guid? ReplyToMessageId { get; set; }
}
@@ -335,151 +335,6 @@ public class IrcBroadcasterTests
// ── SendUserStatusChangedAsync ───────────────────────────────────────
// ── IRCv3 Tags (server-time, msgid, reply) ───────────────────────────
[Fact]
public async Task SendMessage_WithServerTimeCap_IncludesTimeTag()
{
var (conn, stream) = AddConnectionWithCapture("bob", "general");
conn.EnableCap("server-time");
var message = new MessageDto(
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero));
await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.StartsWith("@time=2024-01-15T10:30:00.000Z"));
}
[Fact]
public async Task SendMessage_WithoutServerTimeCap_NoTimeTag()
{
var (_, stream) = AddConnectionWithCapture("bob", "general");
var message = new MessageDto(
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines();
Assert.All(output, l => Assert.DoesNotContain("@time=", l));
}
[Fact]
public async Task SendMessage_WithMessageTagsCap_IncludesMsgid()
{
var (conn, stream) = AddConnectionWithCapture("bob", "general");
conn.EnableCap("message-tags");
var msgId = Guid.NewGuid();
var message = new MessageDto(
msgId, _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains($"msgid={msgId:D}"));
}
[Fact]
public async Task SendMessage_WithMessageTagsAndReply_IncludesReplyTag()
{
var (conn, stream) = AddConnectionWithCapture("bob", "general");
conn.EnableCap("message-tags");
var replyToId = Guid.NewGuid();
var message = new MessageDto(
Guid.NewGuid(), _encryption.Encrypt("Hello!"), "alice", null, "general", DateTimeOffset.UtcNow,
ReplyTo: new ReplyRefDto(replyToId, "bob", _encryption.Encrypt("Original")));
await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains($"+reply={replyToId:D}"));
}
[Fact]
public async Task SendMessage_WithAllTags_FormatsCorrectly()
{
var (conn, stream) = AddConnectionWithCapture("bob", "general");
conn.EnableCap("server-time");
conn.EnableCap("message-tags");
var msgId = Guid.NewGuid();
var sentAt = new DateTimeOffset(2024, 6, 15, 14, 30, 0, TimeSpan.Zero);
var message = new MessageDto(
msgId, _encryption.Encrypt("Hey"), "alice", null, "general", sentAt);
await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.StartsWith("@time=2024-06-15T14:30:00.000Z;msgid="));
}
[Fact]
public async Task SendUserJoined_WithServerTime_IncludesTimeTag()
{
var (conn, stream) = AddConnectionWithCapture("bob", "general");
conn.EnableCap("server-time");
await _broadcaster.SendUserJoinedAsync("general", "alice", null);
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.StartsWith("@time=") && l.Contains("JOIN"));
}
// ── Multiline Batch ──────────────────────────────────────────────────
[Fact]
public async Task SendMessage_WithMultilineCap_WrapsInBatch()
{
var (conn, stream) = AddConnectionWithCapture("bob", "general");
conn.EnableCap("draft/multiline");
conn.EnableCap("batch");
var message = new MessageDto(
Guid.NewGuid(), _encryption.Encrypt("Line1\nLine2"), "alice", null, "general", DateTimeOffset.UtcNow);
await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines();
// BATCH start should use the user's prefix, not the server name
Assert.Contains(output, l => l.Contains(":alice!alice@echohub BATCH +") && l.Contains("draft/multiline"));
Assert.Contains(output, l => l.Contains("BATCH -"));
}
[Fact]
public async Task SendMessage_WithoutMultilineCap_SendsLinesDirectly()
{
var (_, stream) = AddConnectionWithCapture("bob", "general");
var message = new MessageDto(
Guid.NewGuid(), _encryption.Encrypt("Line1\nLine2"), "alice", null, "general", DateTimeOffset.UtcNow);
await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines();
Assert.All(output, l => Assert.DoesNotContain("BATCH", l));
}
[Fact]
public async Task SendMessage_SingleLineWithMultilineCap_NoBatch()
{
var (conn, stream) = AddConnectionWithCapture("bob", "general");
conn.EnableCap("draft/multiline");
conn.EnableCap("batch");
var message = new MessageDto(
Guid.NewGuid(), _encryption.Encrypt("Just one line"), "alice", null, "general", DateTimeOffset.UtcNow);
await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines();
Assert.All(output, l => Assert.DoesNotContain("BATCH", l));
}
[Fact]
public async Task SendUserStatusChanged_IsNoOp()
{
+2 -142
View File
@@ -164,59 +164,11 @@ public class IrcCommandHandlerTests
// ── CAP / SASL ──────────────────────────────────────────────────────
[Fact]
public async Task CapLs_AdvertisesCapabilities()
public async Task CapLs_AdvertisesSasl()
{
var lines = await RunAndCapture(["CAP LS"]);
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("server-time"));
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("message-tags"));
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("batch"));
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("draft/multiline"));
}
[Fact]
public async Task CapLs302_AdvertisesCapabilitiesWithValues()
{
var lines = await RunAndCapture(["CAP LS 302"]);
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("draft/multiline=max-bytes=40000"));
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
}
[Fact]
public async Task CapLs_SuspendsRegistration()
{
_userService.AuthResult = FakeUserService.SuccessResult(Guid.NewGuid(), "alice");
var lines = await RunAndCapture([
"CAP LS",
"PASS secret123",
"NICK alice",
"USER alice 0 * :Alice"
]);
// Should NOT get welcome (001) since CAP END wasn't sent
Assert.DoesNotContain(lines, l => l.Contains("001") && l.Contains("Welcome"));
// Registration should still be pending
Assert.True(lines.All(l => !l.Contains("001")));
}
[Fact]
public async Task CapEnd_ResumesRegistration()
{
_userService.AuthResult = FakeUserService.SuccessResult(Guid.NewGuid(), "alice");
var lines = await RunAndCapture([
"CAP LS",
"PASS secret123",
"NICK alice",
"USER alice 0 * :Alice",
"CAP END"
]);
Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
}
[Fact]
@@ -227,61 +179,12 @@ public class IrcCommandHandlerTests
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl"));
}
[Fact]
public async Task CapReqMultiple_Acknowledged()
{
var lines = await RunAndCapture(["CAP REQ :server-time message-tags"]);
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("server-time") && l.Contains("message-tags"));
}
[Fact]
public async Task CapReqUnknown_GetsNak()
{
var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
Assert.Contains(lines, l => l.Contains("NAK") && l.Contains("multi-prefix"));
}
[Fact]
public async Task CapReqDisableCap_DisablesAndAcks()
{
var (conn, stream) = TestIrcConnectionFactory.Create(
"CAP REQ :message-tags\r\nCAP REQ :-message-tags\r\nCAP END\r\nNICK alice\r\nUSER alice 0 * :Alice\r\n".Split("\r\n", StringSplitOptions.RemoveEmptyEntries));
var handler = new IrcCommandHandler(conn, new IrcOptions { ServerName = "testserver" },
_chatService, _userService, _channelService, _encryption, NullLogger.Instance);
await handler.RunAsync(CancellationToken.None);
var output = stream.GetOutputLines();
// First REQ should ACK
Assert.Contains(output, l => l.Contains("ACK") && l.Contains("message-tags"));
// Second REQ with - should ACK with - prefix
Assert.Contains(output, l => l.Contains("ACK") && l.Contains("-message-tags"));
// Cap should be disabled
Assert.False(conn.HasCap("message-tags"));
}
[Fact]
public async Task CapList_AfterEnable_ShowsEnabledCaps()
{
var lines = await RunAndCapture([
"CAP REQ :server-time",
"CAP LIST"
]);
Assert.Contains(lines, l => l.Contains("LIST") && l.Contains("server-time"));
}
[Fact]
public async Task CapUnknownSubcommand_Gets410()
{
var lines = await RunAndCapture(["CAP FOO"]);
Assert.Contains(lines, l => l.Contains("410") && l.Contains("FOO"));
Assert.Contains(lines, l => l.Contains("NAK"));
}
[Fact]
@@ -790,49 +693,6 @@ public class IrcCommandHandlerTests
Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD
}
// ── BATCH (Multiline) ────────────────────────────────────────────────
[Fact]
public async Task Batch_Multiline_CollectsAndFlushes()
{
var lines = await RunAuthenticated([
"BATCH +abc123 draft/multiline #general",
"@batch=abc123 PRIVMSG #general :Hello",
"@batch=abc123 PRIVMSG #general :world",
"BATCH -abc123"
]);
// Should send a single message with lines joined by newlines
Assert.Single(_chatService.SentMessages);
Assert.Equal("general", _chatService.SentMessages[0].Channel);
Assert.Equal("Hello\nworld", _chatService.SentMessages[0].Content);
}
[Fact]
public async Task Batch_MultilineWithConcat_JoinsWithoutNewline()
{
var lines = await RunAuthenticated([
"BATCH +abc123 draft/multiline #general",
"@batch=abc123 PRIVMSG #general :hello ",
"@batch=abc123;draft/multiline-concat PRIVMSG #general :world",
"BATCH -abc123"
]);
Assert.Single(_chatService.SentMessages);
Assert.Equal("hello world", _chatService.SentMessages[0].Content);
}
[Fact]
public async Task Batch_MultilineEmptyBatch_DoesNothing()
{
var lines = await RunAuthenticated([
"BATCH +abc123 draft/multiline #general",
"BATCH -abc123"
]);
Assert.Empty(_chatService.SentMessages);
}
// ── Unknown command ──────────────────────────────────────────────────
[Fact]
-124
View File
@@ -180,128 +180,4 @@ public class IrcMessageTests
var msg = IrcMessage.Parse("MODE #channel +o alice");
Assert.Equal("alice", msg.Trailing);
}
// ── IRCv3 Message Tags ──────────────────────────────────────────────
[Fact]
public void Parse_WithTags_ExtractsTags()
{
var msg = IrcMessage.Parse("@time=2024-01-01T12:00:00.000Z;msgid=abc PRIVMSG #channel :Hello");
Assert.Equal(2, msg.Tags.Count);
Assert.Equal("2024-01-01T12:00:00.000Z", msg.Tags["time"]);
Assert.Equal("abc", msg.Tags["msgid"]);
Assert.Equal("PRIVMSG", msg.Command);
Assert.Equal("Hello", msg.Parameters[1]);
}
[Fact]
public void Parse_WithClientOnlyTags_ExtractsPlusPrefix()
{
var msg = IrcMessage.Parse("@+reply=abc123;+example.com/tag=val PRIVMSG #channel :Hello");
Assert.Equal(2, msg.Tags.Count);
Assert.Equal("abc123", msg.Tags["+reply"]);
Assert.Equal("val", msg.Tags["+example.com/tag"]);
}
[Fact]
public void Parse_WithTagsAndPrefix_ExtractsBoth()
{
var msg = IrcMessage.Parse("@time=2024-01-01T12:00:00.000Z :alice!user@host PRIVMSG #channel :Hello");
Assert.Single(msg.Tags);
Assert.Equal("alice!user@host", msg.Prefix);
Assert.Equal("PRIVMSG", msg.Command);
Assert.Equal("Hello", msg.Parameters[1]);
}
[Fact]
public void Parse_TagWithEscapedValue_Unescapes()
{
var msg = IrcMessage.Parse("@key=hello\\sworld\\:! PRIVMSG #channel :Hi");
Assert.Single(msg.Tags);
Assert.Equal("hello world;!", msg.Tags["key"]);
}
[Fact]
public void Parse_TagWithNoValue_StoresNull()
{
var msg = IrcMessage.Parse("@empty;key=val PRIVMSG #channel :Hi");
Assert.Equal(2, msg.Tags.Count);
Assert.Null(msg.Tags["empty"]);
Assert.Equal("val", msg.Tags["key"]);
}
[Fact]
public void Parse_BatchTag_ParsesMultilineContext()
{
var msg = IrcMessage.Parse("@batch=abc123 PRIVMSG #channel :line content");
Assert.Single(msg.Tags);
Assert.Equal("abc123", msg.Tags["batch"]);
Assert.Equal("PRIVMSG", msg.Command);
Assert.Equal("line content", msg.Parameters[1]);
}
// ── Tag Escaping ─────────────────────────────────────────────────────
[Fact]
public void TagEscape_HandlesSpecialChars()
{
Assert.Equal("hello\\sworld", IrcMessage.TagEscape("hello world"));
Assert.Equal("a\\:b", IrcMessage.TagEscape("a;b"));
Assert.Equal("a\\\\b", IrcMessage.TagEscape("a\\b"));
Assert.Equal("a\\rb\\n", IrcMessage.TagEscape("a\rb\n"));
// Regular chars pass through unchanged
Assert.Equal("plain-text_123", IrcMessage.TagEscape("plain-text_123"));
}
[Fact]
public void TagUnescape_HandlesEscapeSequences()
{
Assert.Equal("hello world", IrcMessage.TagUnescape("hello\\sworld"));
Assert.Equal("a;b", IrcMessage.TagUnescape("a\\:b"));
Assert.Equal("a\\b", IrcMessage.TagUnescape("a\\\\b"));
Assert.Equal("a\rb\n", IrcMessage.TagUnescape("a\\rb\\n"));
}
// ── BuildTagPrefix ───────────────────────────────────────────────────
[Fact]
public void BuildTagPrefix_NoTags_ReturnsEmpty()
{
Assert.Equal("", IrcMessage.BuildTagPrefix());
}
[Fact]
public void BuildTagPrefix_SingleTag_FormatsCorrectly()
{
var result = IrcMessage.BuildTagPrefix(("time", "2024-01-01T12:00:00.000Z"));
Assert.Equal("@time=2024-01-01T12:00:00.000Z ", result);
}
[Fact]
public void BuildTagPrefix_MultipleTags_SemicolonSeparated()
{
var result = IrcMessage.BuildTagPrefix(("time", "2024-01-01T12:00:00.000Z"), ("msgid", "abc123"));
Assert.Equal("@time=2024-01-01T12:00:00.000Z;msgid=abc123 ", result);
}
[Fact]
public void BuildTagPrefix_TagWithNullValue_OmitsEquals()
{
var result = IrcMessage.BuildTagPrefix(("tag-only", null));
Assert.Equal("@tag-only ", result);
}
[Fact]
public void BuildTagPrefix_ClientOnlyTag_IncludesPlusPrefix()
{
var result = IrcMessage.BuildTagPrefix(("+reply", "msg-123"));
Assert.Equal("@+reply=msg-123 ", result);
}
}