using System.Text;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
namespace EchoHub.Server.Irc;
public static class IrcMessageFormatter
{
private const int MaxIrcLineContentBytes = 400;
///
/// 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.
/// makes the links absolute so any IRC client can open them.
///
public static List FormatMessage(MessageDto message, string? publicBaseUrl = null)
{
var lines = new List();
var ircChannel = $"#{message.ChannelName}";
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
// Caption text first (may be empty when the message is attachments-only)
if (!string.IsNullOrEmpty(message.Content))
{
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
}
// One link line per attachment
if (message.Attachments is { Count: > 0 })
{
foreach (var attachment in message.Attachments)
{
var url = ToAbsoluteUrl(attachment.Url, publicBaseUrl);
var tag = attachment.Kind switch
{
AttachmentKind.Image => $"[Image: {attachment.FileName}]",
AttachmentKind.Audio => $"\u266a [Audio: {attachment.FileName}]",
_ => $"[File: {attachment.FileName}]",
};
lines.Add($"{prefix} PRIVMSG {ircChannel} :{tag} {url}");
}
}
// Append embed previews if present
if (message.Embeds is { Count: > 0 })
{
foreach (var embed in message.Embeds)
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
}
return lines;
}
///
/// Joins a relative attachment path onto the configured public base URL.
/// Already-absolute URLs and unset base URLs pass through unchanged.
///
public static string ToAbsoluteUrl(string url, string? publicBaseUrl)
{
if (string.IsNullOrWhiteSpace(publicBaseUrl) || Uri.IsWellFormedUriString(url, UriKind.Absolute))
return url;
return $"{publicBaseUrl.TrimEnd('/')}/{url.TrimStart('/')}";
}
///
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
///
private static List FormatEmbed(string prefix, string ircChannel, EmbedDto embed)
{
var lines = new List();
var header = new List();
if (!string.IsNullOrWhiteSpace(embed.SiteName))
header.Add(embed.SiteName);
if (!string.IsNullOrWhiteSpace(embed.Title))
header.Add(embed.Title);
if (header.Count > 0)
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {string.Join(" \u2014 ", header)}");
if (!string.IsNullOrWhiteSpace(embed.Description))
{
var desc = embed.Description.Length > 200
? embed.Description[..197] + "..."
: embed.Description;
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {desc}");
}
return lines;
}
///
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
///
public static List SplitMessage(string content, int maxBytes)
{
if (Encoding.UTF8.GetByteCount(content) <= maxBytes)
return [content];
var chunks = new List();
var current = new StringBuilder();
var currentBytes = 0;
foreach (var word in content.Split(' '))
{
var wordBytes = Encoding.UTF8.GetByteCount(word) + 1; // +1 for space
if (currentBytes + wordBytes > maxBytes && current.Length > 0)
{
chunks.Add(current.ToString().TrimEnd());
current.Clear();
currentBytes = 0;
}
current.Append(word).Append(' ');
currentBytes += wordBytes;
}
if (current.Length > 0)
chunks.Add(current.ToString().TrimEnd());
return chunks;
}
}