feat: add image handling actions and improve IRC message formatting

This commit is contained in:
HueByte
2026-07-17 16:52:15 +02:00
parent b9d099dd73
commit 953e081123
13 changed files with 249 additions and 209 deletions
+21 -63
View File
@@ -1,20 +1,20 @@
using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
namespace EchoHub.Server.Irc;
public static partial class IrcMessageFormatter
public static class IrcMessageFormatter
{
private const int MaxIrcLineContentBytes = 400;
/// <summary>
/// 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>
public static List<string> FormatMessage(MessageDto message)
public static List<string> FormatMessage(MessageDto message, string? publicBaseUrl = null)
{
var lines = new List<string>();
var ircChannel = $"#{message.ChannelName}";
@@ -27,34 +27,19 @@ public static partial class IrcMessageFormatter
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
}
// One block per attachment
// One link line per attachment
if (message.Attachments is { Count: > 0 })
{
foreach (var attachment in message.Attachments)
{
switch (attachment.Kind)
var url = ToAbsoluteUrl(attachment.Url, publicBaseUrl);
var tag = attachment.Kind switch
{
case AttachmentKind.Image:
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}");
if (attachment.AsciiPreview is not null && !IsCiphertext(attachment.AsciiPreview))
{
foreach (var line in attachment.AsciiPreview.Split('\n'))
{
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;
}
AttachmentKind.Image => $"[Image: {attachment.FileName}]",
AttachmentKind.Audio => $"\u266a [Audio: {attachment.FileName}]",
_ => $"[File: {attachment.FileName}]",
};
lines.Add($"{prefix} PRIVMSG {ircChannel} :{tag} {url}");
}
}
@@ -69,13 +54,15 @@ public static partial class IrcMessageFormatter
}
/// <summary>
/// True when a preview is still encrypted — transport ($ENC$v1$) if a broadcast path
/// forgot to decrypt it, or E2E room ciphertext ($RC1$) the server cannot decrypt.
/// Emitting it would flood IRC clients with a multi-KB base64 blob.
/// Joins a relative attachment path onto the configured public base URL.
/// Already-absolute URLs and unset base URLs pass through unchanged.
/// </summary>
private static bool IsCiphertext(string text) =>
text.StartsWith(IMessageEncryptionService.CiphertextPrefix, StringComparison.Ordinal)
|| text.StartsWith(RoomCrypto.CiphertextPrefix, StringComparison.Ordinal);
public static string ToAbsoluteUrl(string url, string? publicBaseUrl)
{
if (string.IsNullOrWhiteSpace(publicBaseUrl) || Uri.IsWellFormedUriString(url, UriKind.Absolute))
return url;
return $"{publicBaseUrl.TrimEnd('/')}/{url.TrimStart('/')}";
}
/// <summary>
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
@@ -104,35 +91,6 @@ public static partial class IrcMessageFormatter
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>
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
/// </summary>