mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: add image handling actions and improve IRC message formatting
This commit is contained in:
@@ -110,6 +110,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||||
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
||||||
|
_mainWindow.OnImageOpenRequested += HandleImageOpenRequested;
|
||||||
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
||||||
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||||
@@ -1704,6 +1705,62 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return tempPath;
|
return tempPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>File extensions the "[open]" action will hand to the OS image viewer for E2E rooms.</summary>
|
||||||
|
private static readonly HashSet<string> ImageOpenExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Views an image without saving it to the user's downloads. Plain channels open the
|
||||||
|
/// file's web URL in the default browser (the server serves files by capability URL, so
|
||||||
|
/// no auth token is needed). E2E-encrypted channels would render as ciphertext in a
|
||||||
|
/// browser, so the blob is downloaded, decrypted locally, and opened from a temp file.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleImageOpenRequested(string attachmentUrl, string fileName)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return;
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
var isEncryptedRoom = !string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out _);
|
||||||
|
|
||||||
|
if (!isEncryptedRoom)
|
||||||
|
{
|
||||||
|
var webUrl = attachmentUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| attachmentUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? attachmentUrl
|
||||||
|
: $"{_conn.Api!.BaseUrl}/{attachmentUrl.TrimStart('/')}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(
|
||||||
|
new System.Diagnostics.ProcessStartInfo(webUrl) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to open image URL in browser: {Url}", webUrl);
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Couldn't open a browser — image URL: {webUrl}"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In E2E rooms the attachment kind is sender-declared, so only hand real image
|
||||||
|
// extensions to the OS viewer; anything else goes through the save path instead.
|
||||||
|
if (!ImageOpenExtensions.Contains(Path.GetExtension(fileName)))
|
||||||
|
{
|
||||||
|
HandleImageSaveRequested(attachmentUrl, fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Decrypting {fileName}..."));
|
||||||
|
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
}, "Failed to open image");
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
||||||
{
|
{
|
||||||
if (!_conn.IsAuthenticated) return;
|
if (!_conn.IsAuthenticated) return;
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ using Attribute = Terminal.Gui.Drawing.Attribute;
|
|||||||
|
|
||||||
namespace EchoHub.Client.UI.Chat;
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>An action a click on an attachment line can trigger.</summary>
|
||||||
|
public enum AttachmentAction
|
||||||
|
{
|
||||||
|
OpenImage,
|
||||||
|
SaveImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Inclusive column range on a chat line that triggers an attachment action when clicked.</summary>
|
||||||
|
public readonly record struct AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single line in the chat, composed of colored segments.
|
/// A single line in the chat, composed of colored segments.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -20,6 +30,14 @@ public partial class ChatLine
|
|||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
public AttachmentKind? AttachmentKind { get; set; }
|
public AttachmentKind? AttachmentKind { get; set; }
|
||||||
public string? SenderUsername { get; set; }
|
public string? SenderUsername { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an
|
||||||
|
/// image). Columns are relative to the unwrapped line, so only the first wrapped line
|
||||||
|
/// keeps them. Null means the whole line uses the kind's default action.
|
||||||
|
/// </summary>
|
||||||
|
public List<AttachmentActionSpan>? ActionSpans { get; set; }
|
||||||
|
|
||||||
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||||
public int ContinuationIndent { get; set; }
|
public int ContinuationIndent { get; set; }
|
||||||
|
|
||||||
@@ -160,6 +178,10 @@ public partial class ChatLine
|
|||||||
wrapped.IsMention = IsMention;
|
wrapped.IsMention = IsMention;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Span columns only line up with the first wrapped line; later lines fall
|
||||||
|
// back to the kind's default action.
|
||||||
|
results[0].ActionSpans = ActionSpans;
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -443,9 +443,7 @@ public sealed class ChatMessageManager
|
|||||||
lines.Add(new ChatLine(segments));
|
lines.Add(new ChatLine(segments));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.Add(AttachmentActionLine(
|
lines.Add(ImageActionLine(attachment));
|
||||||
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
|
||||||
ChatColors.FileAttr, attachment));
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Core.Models.AttachmentKind.Audio:
|
case Core.Models.AttachmentKind.Audio:
|
||||||
@@ -489,6 +487,41 @@ public sealed class ChatMessageManager
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the action line below an image preview: "[open] [↓ save original] name [size]".
|
||||||
|
/// Each bracket is an <see cref="AttachmentActionSpan"/> so a mouse click can target it;
|
||||||
|
/// keyboard activation (Enter) uses the default action, open.
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine ImageActionLine(AttachmentDto attachment)
|
||||||
|
{
|
||||||
|
var segments = RailPrefix();
|
||||||
|
var col = segments.Sum(s => s.Text.GetColumns());
|
||||||
|
var spans = new List<AttachmentActionSpan>();
|
||||||
|
|
||||||
|
void AddAction(string text, AttachmentAction action)
|
||||||
|
{
|
||||||
|
var width = text.GetColumns();
|
||||||
|
spans.Add(new AttachmentActionSpan(col, col + width - 1, action));
|
||||||
|
segments.Add(new(text, ChatColors.FileAttr));
|
||||||
|
col += width;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddAction("[open]", AttachmentAction.OpenImage);
|
||||||
|
segments.Add(new(" ", null));
|
||||||
|
col += 1;
|
||||||
|
AddAction("[↓ save original]", AttachmentAction.SaveImage);
|
||||||
|
segments.Add(new($" {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr));
|
||||||
|
|
||||||
|
return new ChatLine(segments)
|
||||||
|
{
|
||||||
|
AttachmentUrl = attachment.Url,
|
||||||
|
AttachmentFileName = attachment.FileName,
|
||||||
|
AttachmentKind = attachment.Kind,
|
||||||
|
ActionSpans = spans,
|
||||||
|
ContinuationPrefixSegments = RailPrefix(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||||
/// route activation (play audio, download file, save original image).
|
/// route activation (play audio, download file, save original image).
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnImageSaveRequested;
|
public event Action<string, string>? OnImageSaveRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates an image's "[open]" action to view it without saving.
|
||||||
|
/// Parameters: attachmentUrl, fileName.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? OnImageOpenRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -532,7 +538,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
if (line.AttachmentKind == AttachmentKind.Image)
|
if (line.AttachmentKind == AttachmentKind.Image)
|
||||||
{
|
{
|
||||||
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
// Keyboard/default activation opens the image for viewing;
|
||||||
|
// saving is the mouse span or the context menu.
|
||||||
|
OnImageOpenRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -600,7 +608,8 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
||||||
{
|
{
|
||||||
if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
var leftClick = e.Flags.HasFlag(MouseFlags.LeftButtonClicked);
|
||||||
|
if (!leftClick && !e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
||||||
@@ -610,6 +619,30 @@ public sealed partial class MainWindow : Runnable
|
|||||||
if (index < 0 || index >= source.Count)
|
if (index < 0 || index >= source.Count)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Left-click only activates the "[open]" / "[save original]" brackets on an
|
||||||
|
// attachment action line; anywhere else it falls through to normal selection.
|
||||||
|
if (leftClick)
|
||||||
|
{
|
||||||
|
var clicked = source.GetLine(index);
|
||||||
|
if (clicked?.ActionSpans is { } spans
|
||||||
|
&& clicked.AttachmentUrl is { } url && clicked.AttachmentFileName is { } name)
|
||||||
|
{
|
||||||
|
foreach (var span in spans)
|
||||||
|
{
|
||||||
|
if (pos.X < span.StartCol || pos.X > span.EndCol)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (span.Action == AttachmentAction.OpenImage)
|
||||||
|
OnImageOpenRequested?.Invoke(url, name);
|
||||||
|
else
|
||||||
|
OnImageSaveRequested?.Invoke(url, name);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
||||||
_messageList.SelectedItem = index;
|
_messageList.SelectedItem = index;
|
||||||
_messageList.SetFocus();
|
_messageList.SetFocus();
|
||||||
@@ -636,6 +669,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
switch (kind)
|
switch (kind)
|
||||||
{
|
{
|
||||||
case AttachmentKind.Image:
|
case AttachmentKind.Image:
|
||||||
|
items.Add(new MenuItem("Open image", "", () => OnImageOpenRequested?.Invoke(url, name), Key.Empty));
|
||||||
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
||||||
break;
|
break;
|
||||||
case AttachmentKind.Audio:
|
case AttachmentKind.Audio:
|
||||||
|
|||||||
@@ -16,17 +16,10 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
|
|
||||||
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
{
|
{
|
||||||
// Decrypt content and attachment previews for IRC clients (they can't handle
|
// Decrypt transport-encrypted content for IRC clients (they can't handle
|
||||||
// app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched;
|
// app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched.
|
||||||
// the formatter drops previews that are still ciphertext.
|
var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) };
|
||||||
var decryptedMessage = message with
|
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
|
||||||
{
|
|
||||||
Content = _encryption.Decrypt(message.Content),
|
|
||||||
Attachments = message.Attachments?
|
|
||||||
.Select(a => a with { AsciiPreview = _encryption.DecryptNullable(a.AsciiPreview) })
|
|
||||||
.ToList(),
|
|
||||||
};
|
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
|
|
||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ public sealed class IrcCommandHandler
|
|||||||
foreach (var m in history)
|
foreach (var m in history)
|
||||||
{
|
{
|
||||||
var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
|
var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decrypted);
|
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
await _conn.SendAsync(line);
|
await _conn.SendAsync(line);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using EchoHub.Core.Contracts;
|
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Core.Security;
|
|
||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
public static partial class IrcMessageFormatter
|
public static class IrcMessageFormatter
|
||||||
{
|
{
|
||||||
private const int MaxIrcLineContentBytes = 400;
|
private const int MaxIrcLineContentBytes = 400;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a MessageDto as one or more IRC PRIVMSG lines.
|
/// Format a MessageDto as one or more IRC PRIVMSG lines.
|
||||||
|
/// Attachments are rendered as single link lines \u2014 the widely-supported IRC convention
|
||||||
|
/// (clients auto-preview or open plain http(s) URLs) \u2014 never as terminal color art.
|
||||||
|
/// <paramref name="publicBaseUrl"/> makes the links absolute so any IRC client can open them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<string> FormatMessage(MessageDto message)
|
public static List<string> FormatMessage(MessageDto message, string? publicBaseUrl = null)
|
||||||
{
|
{
|
||||||
var lines = new List<string>();
|
var lines = new List<string>();
|
||||||
var ircChannel = $"#{message.ChannelName}";
|
var ircChannel = $"#{message.ChannelName}";
|
||||||
@@ -27,34 +27,19 @@ public static partial class IrcMessageFormatter
|
|||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// One block per attachment
|
// One link line per attachment
|
||||||
if (message.Attachments is { Count: > 0 })
|
if (message.Attachments is { Count: > 0 })
|
||||||
{
|
{
|
||||||
foreach (var attachment in message.Attachments)
|
foreach (var attachment in message.Attachments)
|
||||||
{
|
{
|
||||||
switch (attachment.Kind)
|
var url = ToAbsoluteUrl(attachment.Url, publicBaseUrl);
|
||||||
|
var tag = attachment.Kind switch
|
||||||
{
|
{
|
||||||
case AttachmentKind.Image:
|
AttachmentKind.Image => $"[Image: {attachment.FileName}]",
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}");
|
AttachmentKind.Audio => $"\u266a [Audio: {attachment.FileName}]",
|
||||||
if (attachment.AsciiPreview is not null && !IsCiphertext(attachment.AsciiPreview))
|
_ => $"[File: {attachment.FileName}]",
|
||||||
{
|
};
|
||||||
foreach (var line in attachment.AsciiPreview.Split('\n'))
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{tag} {url}");
|
||||||
{
|
|
||||||
var trimmed = line.TrimEnd('\r');
|
|
||||||
if (trimmed.Length > 0)
|
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AttachmentKind.Audio:
|
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {attachment.FileName}] {attachment.Url}");
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {attachment.FileName}] {attachment.Url}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +54,15 @@ public static partial class IrcMessageFormatter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when a preview is still encrypted — transport ($ENC$v1$) if a broadcast path
|
/// Joins a relative attachment path onto the configured public base URL.
|
||||||
/// forgot to decrypt it, or E2E room ciphertext ($RC1$) the server cannot decrypt.
|
/// Already-absolute URLs and unset base URLs pass through unchanged.
|
||||||
/// Emitting it would flood IRC clients with a multi-KB base64 blob.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool IsCiphertext(string text) =>
|
public static string ToAbsoluteUrl(string url, string? publicBaseUrl)
|
||||||
text.StartsWith(IMessageEncryptionService.CiphertextPrefix, StringComparison.Ordinal)
|
{
|
||||||
|| text.StartsWith(RoomCrypto.CiphertextPrefix, StringComparison.Ordinal);
|
if (string.IsNullOrWhiteSpace(publicBaseUrl) || Uri.IsWellFormedUriString(url, UriKind.Absolute))
|
||||||
|
return url;
|
||||||
|
return $"{publicBaseUrl.TrimEnd('/')}/{url.TrimStart('/')}";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
||||||
@@ -104,35 +91,6 @@ public static partial class IrcMessageFormatter
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients.
|
|
||||||
/// Also passes through content that already uses ANSI codes unchanged.
|
|
||||||
/// </summary>
|
|
||||||
public static string ColorTagsToAnsi(string text)
|
|
||||||
{
|
|
||||||
if (!text.Contains('{'))
|
|
||||||
return text;
|
|
||||||
|
|
||||||
return ColorTagRegex().Replace(text, match =>
|
|
||||||
{
|
|
||||||
if (match.Groups[1].Success) // {X} reset
|
|
||||||
return "\x1b[0m";
|
|
||||||
if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB}
|
|
||||||
{
|
|
||||||
var hex = match.Groups[3].Value;
|
|
||||||
var r = Convert.ToInt32(hex[..2], 16);
|
|
||||||
var g = Convert.ToInt32(hex[2..4], 16);
|
|
||||||
var b = Convert.ToInt32(hex[4..6], 16);
|
|
||||||
var code = match.Groups[2].Value == "F" ? "38" : "48";
|
|
||||||
return $"\x1b[{code};2;{r};{g};{b}m";
|
|
||||||
}
|
|
||||||
return match.Value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
|
||||||
private static partial Regex ColorTagRegex();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -12,4 +12,11 @@ public sealed class IrcOptions
|
|||||||
public string? TlsCertPassword { get; set; }
|
public string? TlsCertPassword { get; set; }
|
||||||
public string ServerName { get; set; } = "echohub";
|
public string ServerName { get; set; } = "echohub";
|
||||||
public string? Motd { get; set; }
|
public string? Motd { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public HTTP(S) base of this EchoHub server (e.g. "https://chat.example.com"),
|
||||||
|
/// used to turn relative attachment URLs into absolute links IRC clients can open.
|
||||||
|
/// When unset, attachment lines fall back to the relative path.
|
||||||
|
/// </summary>
|
||||||
|
public string? PublicBaseUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ public class FilesController : ControllerBase
|
|||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves an uploaded file. Anonymous by design: the unguessable GUID in the URL is the
|
||||||
|
/// access token (Discord-CDN-style capability URL), so attachment links can be opened
|
||||||
|
/// directly in a browser and shared to IRC clients. E2E-encrypted room blobs are
|
||||||
|
/// ciphertext at rest, so anonymous access reveals nothing for those channels.
|
||||||
|
/// </summary>
|
||||||
[HttpGet("{fileId}")]
|
[HttpGet("{fileId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public IActionResult GetFile(string fileId)
|
public IActionResult GetFile(string fileId)
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(fileId, out _))
|
if (!Guid.TryParse(fileId, out _))
|
||||||
@@ -48,6 +55,11 @@ public class FilesController : ControllerBase
|
|||||||
_ => "application/octet-stream"
|
_ => "application/octet-stream"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Images and audio render inline so a browser displays them instead of
|
||||||
|
// downloading; everything else keeps the attachment disposition.
|
||||||
|
if (contentType.StartsWith("image/") || contentType.StartsWith("audio/"))
|
||||||
|
return PhysicalFile(filePath, contentType);
|
||||||
|
|
||||||
var fileName = Path.GetFileName(filePath);
|
var fileName = Path.GetFileName(filePath);
|
||||||
return PhysicalFile(filePath, contentType, fileName);
|
return PhysicalFile(filePath, contentType, fileName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,13 +62,11 @@ public class DataMigrationServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi()
|
public void AnsiToColorTags_ForegroundBackgroundAndReset_AllConverted()
|
||||||
{
|
{
|
||||||
// AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses
|
var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;255;0mgreen\x1b[0m";
|
||||||
var original = "{F:FF0000}red{B:00FF00}green{X}";
|
|
||||||
var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original);
|
|
||||||
var backToTags = DataMigrationService.AnsiToColorTags(ansi);
|
var backToTags = DataMigrationService.AnsiToColorTags(ansi);
|
||||||
|
|
||||||
Assert.Equal(original, backToTags);
|
Assert.Equal("{F:FF0000}red{B:00FF00}green{X}", backToTags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ public class IrcBroadcasterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendMessage_DecryptsAttachmentAsciiPreview()
|
public async Task SendMessage_ImageAttachment_SendsLinkLineOnly()
|
||||||
{
|
{
|
||||||
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
@@ -104,9 +104,9 @@ public class IrcBroadcasterTests
|
|||||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
var output = stream.GetOutputLines();
|
var output = stream.GetOutputLines();
|
||||||
Assert.Contains(output, l => l.Contains("[Image: photo.png]"));
|
Assert.Contains(output, l => l.Contains("[Image: photo.png]") && l.Contains("/api/files/abc"));
|
||||||
Assert.Contains(output, l => l.Contains("line1"));
|
// ASCII preview art is never sent to IRC clients — images are links only
|
||||||
Assert.Contains(output, l => l.Contains("line2"));
|
Assert.DoesNotContain(output, l => l.Contains("line1"));
|
||||||
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,37 +118,50 @@ public class IrcMessageFormatterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_ImageMessage_IncludesAsciiArt()
|
public void FormatMessage_ImageMessage_NeverEmitsAsciiArt()
|
||||||
{
|
{
|
||||||
var msg = CreateImageMessage("line1\nline2");
|
// Images are shared as plain links (the common IRC practice) — never color art,
|
||||||
|
// regardless of what the preview contains.
|
||||||
|
var msg = CreateImageMessage("{F:FF0000}█{X}\nline2");
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("line1"));
|
Assert.Single(lines);
|
||||||
Assert.Contains(lines, l => l.Contains("line2"));
|
Assert.Contains("[Image: image.png]", lines[0]);
|
||||||
|
Assert.DoesNotContain(lines, l => l.Contains("line2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_ImageMessage_SkipsEmptyAsciiLines()
|
public void FormatMessage_RelativeUrl_JoinedWithPublicBaseUrl()
|
||||||
{
|
{
|
||||||
var msg = CreateImageMessage("line1\n\nline2");
|
var msg = CreateImageMessage("art", "photo.jpg", "/api/files/abc");
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com");
|
||||||
|
|
||||||
// Empty lines should be skipped
|
Assert.Single(lines);
|
||||||
var asciiLines = lines.Where(l => !l.Contains("[Image:") && !l.Contains("Download:")).ToList();
|
Assert.Contains("https://chat.example.com/api/files/abc", lines[0]);
|
||||||
Assert.Equal(2, asciiLines.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData("$ENC$v1$abc123$def456")] // transport ciphertext a broadcast path forgot to decrypt
|
public void FormatMessage_AbsoluteUrl_NotRewrittenByPublicBaseUrl()
|
||||||
[InlineData("$RC1$abc123def456")] // E2E room ciphertext the server cannot decrypt
|
|
||||||
public void FormatMessage_ImageMessage_SkipsCiphertextPreview(string ciphertextPreview)
|
|
||||||
{
|
{
|
||||||
var msg = CreateImageMessage(ciphertextPreview, "photo.jpg", "https://example.com/photo.jpg");
|
var msg = CreateImageMessage("art", "photo.jpg", "https://cdn.example.com/photo.jpg");
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com");
|
||||||
|
|
||||||
// Only the [Image: ...] header line — never the ciphertext blob
|
Assert.Contains("https://cdn.example.com/photo.jpg", lines[0]);
|
||||||
Assert.Single(lines);
|
Assert.DoesNotContain("https://chat.example.com", lines[0]);
|
||||||
Assert.Contains("[Image: photo.jpg]", lines[0]);
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToAbsoluteUrl_NoBaseUrl_ReturnsRelativeUnchanged()
|
||||||
|
{
|
||||||
|
Assert.Equal("/api/files/abc", IrcMessageFormatter.ToAbsoluteUrl("/api/files/abc", null));
|
||||||
|
Assert.Equal("/api/files/abc", IrcMessageFormatter.ToAbsoluteUrl("/api/files/abc", " "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToAbsoluteUrl_TrailingSlashBase_JoinsWithoutDoubleSlash()
|
||||||
|
{
|
||||||
|
Assert.Equal("https://x.example/api/files/1",
|
||||||
|
IrcMessageFormatter.ToAbsoluteUrl("/api/files/1", "https://x.example/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -244,62 +257,4 @@ public class IrcMessageFormatterTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ColorTagsToAnsi ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_NoTags_ReturnsUnchanged()
|
|
||||||
{
|
|
||||||
Assert.Equal("Hello world", IrcMessageFormatter.ColorTagsToAnsi("Hello world"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsi()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red text");
|
|
||||||
Assert.Equal("\x1b[38;2;255;0;0mRed text", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsi()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}Green bg");
|
|
||||||
Assert.Equal("\x1b[48;2;0;255;0mGreen bg", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ResetTag_ConvertsToReset()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red{X} Normal");
|
|
||||||
Assert.Equal("\x1b[38;2;255;0;0mRed\x1b[0m Normal", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_MultipleTags_ConvertsAll()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red {F:0000FF}Blue{X}");
|
|
||||||
Assert.Contains("\x1b[38;2;255;0;0m", result);
|
|
||||||
Assert.Contains("\x1b[38;2;0;0;255m", result);
|
|
||||||
Assert.Contains("\x1b[0m", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_LowercaseHex_ConvertsCorrectly()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:ff8800}text");
|
|
||||||
Assert.Equal("\x1b[38;2;255;136;0mtext", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_NoBraces_SkipsProcessing()
|
|
||||||
{
|
|
||||||
var text = "plain text without braces";
|
|
||||||
Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ExistingAnsiCodes_PreservesUnchanged()
|
|
||||||
{
|
|
||||||
var text = "\x1b[31mAlready colored\x1b[0m";
|
|
||||||
Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,24 @@ public class IrcMessageFormatterTests
|
|||||||
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]);
|
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]);
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.True(lines.Count >= 2);
|
// Images are a single link line — the ASCII preview is never sent to IRC clients
|
||||||
|
Assert.Single(lines);
|
||||||
Assert.Contains("[Image: photo.png]", lines[0]);
|
Assert.Contains("[Image: photo.png]", lines[0]);
|
||||||
Assert.Contains("/api/files/abc", lines[0]);
|
Assert.Contains("/api/files/abc", lines[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatMessage_WithPublicBaseUrl_EmitsAbsoluteAttachmentLinks()
|
||||||
|
{
|
||||||
|
var msg = CreateMessage(
|
||||||
|
content: "",
|
||||||
|
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, null)]);
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com/");
|
||||||
|
|
||||||
|
Assert.Single(lines);
|
||||||
|
Assert.Contains("https://chat.example.com/api/files/abc", lines[0]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_FileAttachment_IncludesFileTag()
|
public void FormatMessage_FileAttachment_IncludesFileTag()
|
||||||
{
|
{
|
||||||
@@ -121,48 +134,6 @@ public class IrcMessageFormatterTests
|
|||||||
Assert.Contains(lines, l => l.Contains("[File: c.pdf]"));
|
Assert.Contains(lines, l => l.Contains("[File: c.pdf]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ColorTagsToAnsi ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsiEscape()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}text");
|
|
||||||
Assert.Contains("\x1b[38;2;255;0;0m", result);
|
|
||||||
Assert.Contains("text", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsiEscape()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}text");
|
|
||||||
Assert.Contains("\x1b[48;2;0;255;0m", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ResetTag_ConvertsToAnsiReset()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{X}");
|
|
||||||
Assert.Equal("\x1b[0m", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_NoTags_ReturnsUnchanged()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("plain text");
|
|
||||||
Assert.Equal("plain text", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_MultipleTags_ConvertsAll()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}red{F:0000FF}blue{X}");
|
|
||||||
Assert.Contains("\x1b[38;2;255;0;0m", result);
|
|
||||||
Assert.Contains("\x1b[38;2;0;0;255m", result);
|
|
||||||
Assert.Contains("\x1b[0m", result);
|
|
||||||
Assert.Contains("red", result);
|
|
||||||
Assert.Contains("blue", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SplitMessage ──────────────────────────────────────────────────
|
// ── SplitMessage ──────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user