mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 23:34:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5f8eeded |
@@ -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`*
|
|
||||||
@@ -4,7 +4,6 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## 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.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.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
|
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
- name: v0.2.18
|
|
||||||
href: v0.2.18.md
|
|
||||||
- name: v0.2.17
|
- name: v0.2.17
|
||||||
href: v0.2.17.md
|
href: v0.2.17.md
|
||||||
- name: v0.2.16
|
- name: v0.2.16
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -23,4 +23,3 @@
|
|||||||
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
|
- [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
|
- [x] space between mod|admin "icon" and username
|
||||||
- [ ] Embeds still incorrectly display colors
|
- [ ] 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>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.18</Version>
|
<Version>0.2.17</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly ChatMessageManager _messageManager;
|
private readonly ChatMessageManager _messageManager;
|
||||||
private readonly CommandHandler _commandHandler;
|
private readonly CommandHandler _commandHandler;
|
||||||
private readonly NotificationSoundService _notificationSound;
|
private readonly NotificationSoundService _notificationSound;
|
||||||
private readonly OsNotificationService _osNotification;
|
|
||||||
private readonly AudioPlaybackService _audioPlayback = new();
|
private readonly AudioPlaybackService _audioPlayback = new();
|
||||||
private readonly UpdateChecker _updateService;
|
private readonly UpdateChecker _updateService;
|
||||||
private readonly ConnectionManager _conn = new();
|
private readonly ConnectionManager _conn = new();
|
||||||
@@ -66,7 +65,6 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow = new MainWindow(app, _messageManager);
|
_mainWindow = new MainWindow(app, _messageManager);
|
||||||
_commandHandler = new CommandHandler();
|
_commandHandler = new CommandHandler();
|
||||||
_notificationSound = new NotificationSoundService(config.Notifications);
|
_notificationSound = new NotificationSoundService(config.Notifications);
|
||||||
_osNotification = new OsNotificationService();
|
|
||||||
_updateService = new UpdateChecker(app);
|
_updateService = new UpdateChecker(app);
|
||||||
|
|
||||||
WireMainWindowEvents();
|
WireMainWindowEvents();
|
||||||
@@ -1107,9 +1105,6 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
&& message.Content.Contains($"@{_session.Username}", StringComparison.OrdinalIgnoreCase))
|
&& message.Content.Contains($"@{_session.Username}", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_ = _notificationSound.PlayAsync();
|
_ = _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,7 +9,6 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||||
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
||||||
<PackageReference Include="OsNotifications" Version="1.1.5" />
|
|
||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,123 +23,28 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
Content = _encryption.Decrypt(message.Content),
|
Content = _encryption.Decrypt(message.Content),
|
||||||
ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
|
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))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
// Don't echo back to traditional IRC clients (they display locally).
|
// IRC convention: don't echo a message back to the connection that sent it
|
||||||
// Echo-message clients need their own messages back for msgid/+reply tracking.
|
// (its client already displayed it locally). Match by connection id, not
|
||||||
if (conn.ConnectionId == excludeConnectionId && !conn.HasCap("echo-message"))
|
// nickname — the same account may also be online via the TUI or a second
|
||||||
|
// IRC client, and those sessions must still receive the message.
|
||||||
|
if (conn.ConnectionId == excludeConnectionId)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// 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)
|
foreach (var line in lines)
|
||||||
await conn.SendAsync(tagPrefix + line);
|
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)
|
public async Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
if (conn.ConnectionId == excludeConnectionId) continue;
|
if (conn.ConnectionId == excludeConnectionId) continue;
|
||||||
var line = $":{username}!{username}@echohub JOIN #{channelName}";
|
await conn.SendAsync($":{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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,14 +53,7 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
if (conn.Nickname == username) continue;
|
if (conn.Nickname == username) continue;
|
||||||
var line = $":{username}!{username}@echohub PART #{channelName}";
|
await conn.SendAsync($":{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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,14 +64,7 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(target))
|
foreach (var conn in _gateway.GetConnectionsInChannel(target))
|
||||||
{
|
{
|
||||||
var line = $":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}";
|
await conn.SendAsync($":{_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,22 +28,10 @@ public sealed class IrcClientConnection : IAsyncDisposable
|
|||||||
public bool IsSasl { get; set; }
|
public bool IsSasl { get; set; }
|
||||||
public bool CapNegotiating { 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
|
// Channel state — thread-safe: written by command handler, read by broadcaster threads
|
||||||
private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly object _channelLock = new();
|
private readonly object _channelLock = new();
|
||||||
|
|
||||||
// Non-null while BATCH lines are being collected
|
|
||||||
public MultilineBatchContext? PendingMultilineBatch { get; set; }
|
|
||||||
|
|
||||||
// Away state
|
// Away state
|
||||||
public string? AwayMessage { get; set; }
|
public string? AwayMessage { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -19,16 +19,6 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private string ServerName => _options.ServerName;
|
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(
|
public IrcCommandHandler(
|
||||||
IrcClientConnection conn,
|
IrcClientConnection conn,
|
||||||
IrcOptions options,
|
IrcOptions options,
|
||||||
@@ -92,7 +82,6 @@ public sealed class IrcCommandHandler
|
|||||||
"JOIN" => HandleJoinAsync(msg),
|
"JOIN" => HandleJoinAsync(msg),
|
||||||
"PART" => HandlePartAsync(msg),
|
"PART" => HandlePartAsync(msg),
|
||||||
"PRIVMSG" => HandlePrivmsgAsync(msg),
|
"PRIVMSG" => HandlePrivmsgAsync(msg),
|
||||||
"NOTICE" => Task.CompletedTask,
|
|
||||||
"QUIT" => HandleQuitAsync(msg),
|
"QUIT" => HandleQuitAsync(msg),
|
||||||
"NAMES" => HandleNamesAsync(msg),
|
"NAMES" => HandleNamesAsync(msg),
|
||||||
"TOPIC" => HandleTopicAsync(msg),
|
"TOPIC" => HandleTopicAsync(msg),
|
||||||
@@ -104,46 +93,36 @@ public sealed class IrcCommandHandler
|
|||||||
"MOTD" => SendMotdAsync(),
|
"MOTD" => SendMotdAsync(),
|
||||||
"USERHOST" or "LUSERS" => Task.CompletedTask,
|
"USERHOST" or "LUSERS" => Task.CompletedTask,
|
||||||
|
|
||||||
// IRCv3 multiline batch
|
|
||||||
"BATCH" => HandleBatchAsync(msg),
|
|
||||||
|
|
||||||
_ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND,
|
_ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND,
|
||||||
$"{command} :Unknown command"),
|
$"{command} :Unknown command"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── IRCv3 CAP Negotiation ─────────────────────────────────────────────
|
// ── Authentication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
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)
|
private async Task HandleCapAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (msg.Parameters.Count < 1)
|
if (msg.Parameters.Count < 1) return;
|
||||||
{
|
|
||||||
await SendInvalidCapCmdAsync("CAP requires a subcommand");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var subcommand = msg.Parameters[0].ToUpperInvariant();
|
switch (msg.Parameters[0].ToUpperInvariant())
|
||||||
|
|
||||||
// Build the nick placeholder for server responses (use * while unregistered)
|
|
||||||
var nick = _conn.Nickname ?? "*";
|
|
||||||
|
|
||||||
switch (subcommand)
|
|
||||||
{
|
{
|
||||||
case "LS":
|
case "LS":
|
||||||
await HandleCapLsAsync(msg, nick);
|
await _conn.SendAsync($":{ServerName} CAP * LS :sasl");
|
||||||
break;
|
_conn.CapNegotiating = true;
|
||||||
|
|
||||||
case "LIST":
|
|
||||||
await HandleCapListAsync(nick);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "REQ":
|
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;
|
break;
|
||||||
|
|
||||||
case "END":
|
case "END":
|
||||||
@@ -151,247 +130,9 @@ public sealed class IrcCommandHandler
|
|||||||
if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered)
|
if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered)
|
||||||
await TryCompleteRegistrationAsync();
|
await TryCompleteRegistrationAsync();
|
||||||
break;
|
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)
|
private async Task HandleAuthenticateAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
@@ -587,26 +328,8 @@ public sealed class IrcCommandHandler
|
|||||||
$":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}");
|
$":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}");
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
|
||||||
$"{ServerName} EchoHub-IRC o o");
|
$"{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,
|
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();
|
await SendMotdAsync();
|
||||||
}
|
}
|
||||||
@@ -726,7 +449,7 @@ public sealed class IrcCommandHandler
|
|||||||
};
|
};
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
|
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
|
||||||
foreach (var line in lines)
|
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);
|
var channelName = IrcToEchoHubChannel(target);
|
||||||
if (channelName is null) return;
|
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(
|
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)
|
if (error is not null)
|
||||||
{
|
{
|
||||||
@@ -1129,24 +816,4 @@ public sealed class IrcCommandHandler
|
|||||||
var name = ircChannel[1..].ToLowerInvariant().Trim();
|
var name = ircChannel[1..].ToLowerInvariant().Trim();
|
||||||
return ValidationConstants.ChannelNameRegex().IsMatch(name) ? name : null;
|
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,14 +1,11 @@
|
|||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parsed representation of an IRC protocol line.
|
/// Parsed representation of an IRC protocol line.
|
||||||
/// IRCv3 format: ['@' tags ' '] [':' prefix ' '] COMMAND [params...] [':' trailing]
|
/// Format: [:prefix] COMMAND [params...] [:trailing]
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IrcMessage
|
public sealed class IrcMessage
|
||||||
{
|
{
|
||||||
public Dictionary<string, string?> Tags { get; init; } = new();
|
|
||||||
public string? Prefix { get; init; }
|
public string? Prefix { get; init; }
|
||||||
public string Command { get; init; } = "";
|
public string Command { get; init; } = "";
|
||||||
public List<string> Parameters { 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;
|
public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a raw IRC line with optional IRCv3 message tags.
|
/// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF
|
||||||
/// Format: ['@' tags ' '] [':' prefix ' '] COMMAND params CRLF
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IrcMessage Parse(string line)
|
public static IrcMessage Parse(string line)
|
||||||
{
|
{
|
||||||
var span = line.AsSpan().TrimEnd("\r\n");
|
var span = line.AsSpan().TrimEnd("\r\n");
|
||||||
|
string? prefix = null;
|
||||||
var pos = 0;
|
var pos = 0;
|
||||||
|
|
||||||
if (span.Length == 0)
|
// Parse optional prefix
|
||||||
return new IrcMessage { Command = "" };
|
if (span.Length > 0 && span[0] == ':')
|
||||||
|
|
||||||
var tags = new Dictionary<string, string?>();
|
|
||||||
|
|
||||||
// Parse optional IRCv3 message tags: @tag1;tag2=val2;...
|
|
||||||
if (span[pos] == '@')
|
|
||||||
{
|
{
|
||||||
var spaceIdx = span.IndexOf(' ');
|
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)
|
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();
|
prefix = span[1..spaceIdx].ToString();
|
||||||
pos = pos + spaceIdx + 1;
|
pos = spaceIdx + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip whitespace
|
// Skip whitespace
|
||||||
while (pos < span.Length && span[pos] == ' ') pos++;
|
while (pos < span.Length && span[pos] == ' ') pos++;
|
||||||
|
|
||||||
// Parse command
|
// Parse command
|
||||||
if (pos >= span.Length)
|
|
||||||
return new IrcMessage { Tags = tags, Prefix = prefix, Command = "" };
|
|
||||||
|
|
||||||
var cmdStart = pos;
|
var cmdStart = pos;
|
||||||
while (pos < span.Length && span[pos] != ' ') pos++;
|
while (pos < span.Length && span[pos] != ' ') pos++;
|
||||||
var command = span[cmdStart..pos].ToString();
|
var command = span[cmdStart..pos].ToString();
|
||||||
@@ -99,78 +61,9 @@ public sealed class IrcMessage
|
|||||||
|
|
||||||
return new IrcMessage
|
return new IrcMessage
|
||||||
{
|
{
|
||||||
Tags = tags,
|
|
||||||
Prefix = prefix,
|
Prefix = prefix,
|
||||||
Command = command,
|
Command = command,
|
||||||
Parameters = parameters,
|
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";
|
public const string RPL_ENDOFBANLIST = "368";
|
||||||
|
|
||||||
// Errors
|
// Errors
|
||||||
public const string ERR_INVALIDCAPCMD = "410";
|
|
||||||
public const string ERR_NOSUCHNICK = "401";
|
public const string ERR_NOSUCHNICK = "401";
|
||||||
public const string ERR_NOSUCHCHANNEL = "403";
|
public const string ERR_NOSUCHCHANNEL = "403";
|
||||||
public const string ERR_CANNOTSENDTOCHAN = "404";
|
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 ───────────────────────────────────────
|
// ── 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]
|
[Fact]
|
||||||
public async Task SendUserStatusChanged_IsNoOp()
|
public async Task SendUserStatusChanged_IsNoOp()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -164,59 +164,11 @@ public class IrcCommandHandlerTests
|
|||||||
// ── CAP / SASL ──────────────────────────────────────────────────────
|
// ── CAP / SASL ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CapLs_AdvertisesCapabilities()
|
public async Task CapLs_AdvertisesSasl()
|
||||||
{
|
{
|
||||||
var lines = await RunAndCapture(["CAP LS"]);
|
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("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]
|
[Fact]
|
||||||
@@ -227,61 +179,12 @@ public class IrcCommandHandlerTests
|
|||||||
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl"));
|
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]
|
[Fact]
|
||||||
public async Task CapReqUnknown_GetsNak()
|
public async Task CapReqUnknown_GetsNak()
|
||||||
{
|
{
|
||||||
var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
|
var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("NAK") && l.Contains("multi-prefix"));
|
Assert.Contains(lines, l => l.Contains("NAK"));
|
||||||
}
|
|
||||||
|
|
||||||
[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]
|
[Fact]
|
||||||
@@ -790,49 +693,6 @@ public class IrcCommandHandlerTests
|
|||||||
Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD
|
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 ──────────────────────────────────────────────────
|
// ── Unknown command ──────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -180,128 +180,4 @@ public class IrcMessageTests
|
|||||||
var msg = IrcMessage.Parse("MODE #channel +o alice");
|
var msg = IrcMessage.Parse("MODE #channel +o alice");
|
||||||
Assert.Equal("alice", msg.Trailing);
|
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