mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a247f458b | ||
|
|
fba8fbe49b | ||
|
|
ac067b2c0b | ||
|
|
d1d16f6f0e | ||
|
|
338946382f | ||
|
|
5d1934e2c6 | ||
|
|
441a97e8c9 | ||
|
|
11ce586f29 | ||
|
|
b28b98e336 | ||
|
|
87f2cbb011 |
@@ -0,0 +1,42 @@
|
||||
# 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`*
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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.
|
||||
@@ -23,3 +23,4 @@
|
||||
- [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,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.17</Version>
|
||||
<Version>0.2.18</Version>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -27,6 +27,7 @@ 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();
|
||||
@@ -65,6 +66,7 @@ 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();
|
||||
@@ -1105,6 +1107,9 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,28 +23,123 @@ public class IrcBroadcaster : IChatBroadcaster
|
||||
Content = _encryption.Decrypt(message.Content),
|
||||
ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
|
||||
};
|
||||
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
|
||||
|
||||
// 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");
|
||||
|
||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||
{
|
||||
// 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)
|
||||
// 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"))
|
||||
continue;
|
||||
|
||||
foreach (var line in lines)
|
||||
await conn.SendAsync(line);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +148,14 @@ public class IrcBroadcaster : IChatBroadcaster
|
||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||
{
|
||||
if (conn.Nickname == username) continue;
|
||||
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +166,14 @@ public class IrcBroadcaster : IChatBroadcaster
|
||||
|
||||
foreach (var conn in _gateway.GetConnectionsInChannel(target))
|
||||
{
|
||||
await conn.SendAsync($":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,22 @@ 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,6 +19,16 @@ 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,
|
||||
@@ -82,6 +92,7 @@ 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),
|
||||
@@ -93,36 +104,46 @@ 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"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Authentication ──────────────────────────────────────────────────────
|
||||
// ── 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];
|
||||
|
||||
private async Task HandleCapAsync(IrcMessage msg)
|
||||
{
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
if (msg.Parameters.Count < 1)
|
||||
{
|
||||
await SendInvalidCapCmdAsync("CAP requires a subcommand");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.Parameters[0].ToUpperInvariant())
|
||||
var subcommand = msg.Parameters[0].ToUpperInvariant();
|
||||
|
||||
// Build the nick placeholder for server responses (use * while unregistered)
|
||||
var nick = _conn.Nickname ?? "*";
|
||||
|
||||
switch (subcommand)
|
||||
{
|
||||
case "LS":
|
||||
await _conn.SendAsync($":{ServerName} CAP * LS :sasl");
|
||||
_conn.CapNegotiating = true;
|
||||
await HandleCapLsAsync(msg, nick);
|
||||
break;
|
||||
|
||||
case "LIST":
|
||||
await HandleCapListAsync(nick);
|
||||
break;
|
||||
|
||||
case "REQ":
|
||||
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}");
|
||||
}
|
||||
await HandleCapReqAsync(msg, nick);
|
||||
break;
|
||||
|
||||
case "END":
|
||||
@@ -130,9 +151,247 @@ 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;
|
||||
@@ -328,8 +587,26 @@ 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,
|
||||
"CHANTYPES=# CHANMODES=b,k,, NICKLEN=50 CHANNELLEN=100 :are supported by this server");
|
||||
string.Join(" ", isupportTokens));
|
||||
|
||||
await SendMotdAsync();
|
||||
}
|
||||
@@ -449,7 +726,7 @@ public sealed class IrcCommandHandler
|
||||
};
|
||||
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
|
||||
foreach (var line in lines)
|
||||
await _conn.SendAsync(line);
|
||||
await _conn.SendAsync(DecorateLineForConnection(line, m));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,8 +776,44 @@ 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);
|
||||
_conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId, replyTo);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
@@ -816,4 +1129,24 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System.Text;
|
||||
|
||||
namespace EchoHub.Server.Irc;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed representation of an IRC protocol line.
|
||||
/// Format: [:prefix] COMMAND [params...] [:trailing]
|
||||
/// IRCv3 format: ['@' tags ' '] [':' 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; } = [];
|
||||
@@ -13,29 +16,64 @@ public sealed class IrcMessage
|
||||
public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF
|
||||
/// Parse a raw IRC line with optional IRCv3 message tags.
|
||||
/// Format: ['@' tags ' '] [':' prefix ' '] COMMAND params CRLF
|
||||
/// </summary>
|
||||
public static IrcMessage Parse(string line)
|
||||
{
|
||||
var span = line.AsSpan().TrimEnd("\r\n");
|
||||
string? prefix = null;
|
||||
var pos = 0;
|
||||
|
||||
// Parse optional prefix
|
||||
if (span.Length > 0 && span[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] == '@')
|
||||
{
|
||||
var spaceIdx = span.IndexOf(' ');
|
||||
if (spaceIdx == -1)
|
||||
return new IrcMessage { Prefix = span[1..].ToString() };
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
prefix = span[1..spaceIdx].ToString();
|
||||
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 = "" };
|
||||
|
||||
prefix = span.Slice(pos + 1, spaceIdx - 1).ToString();
|
||||
pos = 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();
|
||||
@@ -61,9 +99,78 @@ 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,6 +45,7 @@ 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";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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,6 +335,151 @@ 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()
|
||||
{
|
||||
|
||||
@@ -164,11 +164,59 @@ public class IrcCommandHandlerTests
|
||||
// ── CAP / SASL ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CapLs_AdvertisesSasl()
|
||||
public async Task CapLs_AdvertisesCapabilities()
|
||||
{
|
||||
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]
|
||||
@@ -179,12 +227,61 @@ 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"));
|
||||
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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -693,6 +790,49 @@ 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]
|
||||
|
||||
@@ -180,4 +180,128 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user