Files

188 lines
6.9 KiB
C#

using System.Text;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Telegram.Rendering;
/// <summary>
/// Turns the model's Markdown into Telegram HTML and splits long text into message-sized chunks.
/// The output is always well formed: everything is escaped and tags are only emitted in matched pairs, so partial
/// (still streaming) Markdown renders too.
/// </summary>
public static partial class TelegramFormatter
{
/// <summary>Telegram's limit for one message, in UTF-16 code units after entity parsing.</summary>
public const int MessageLimit = 4096;
/// <summary>Raw text per chunk; leaves room for the HTML tags and escapes the conversion adds.</summary>
public const int ChunkLimit = 3500;
private const string Fence = "```";
/// <summary>Escape the three characters Telegram HTML requires; everything else (emoji included) stays as is.</summary>
public static string Escape(string text) => text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
/// <summary>Convert Markdown to Telegram HTML.</summary>
public static string ToHtml(string markdown)
{
var html = new StringBuilder();
var lines = markdown.Replace("\r\n", "\n").Split('\n');
var inCode = false;
string? language = null;
var code = new StringBuilder();
void CloseCode()
{
var body = Escape(code.ToString().TrimEnd('\n'));
html.Append(language is { Length: > 0 }
? $"<pre><code class=\"language-{Escape(language)}\">{body}</code></pre>"
: $"<pre>{body}</pre>");
code.Clear();
}
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
if (line.TrimStart().StartsWith(Fence, StringComparison.Ordinal))
{
if (inCode)
{
CloseCode();
inCode = false;
}
else
{
inCode = true;
language = line.TrimStart()[Fence.Length..].Trim();
}
if (i < lines.Length - 1 && !inCode) html.Append('\n');
continue;
}
if (inCode)
{
code.Append(line).Append('\n');
continue;
}
html.Append(FormatLine(line));
if (i < lines.Length - 1) html.Append('\n');
}
if (inCode) CloseCode(); // unterminated fence while streaming: show what we have as code
return html.ToString();
}
private static string FormatLine(string line)
{
if (HeadingRegex().Match(line) is { Success: true } heading) return $"<b>{FormatInline(heading.Groups[1].Value)}</b>";
if (BulletRegex().Match(line) is { Success: true } bullet) return $"{bullet.Groups[1].Value}• {FormatInline(bullet.Groups[2].Value)}";
return FormatInline(line);
}
/// <summary>Inline code, bold, italics, strikethrough and links. Code spans are cut out first so their content stays literal.</summary>
private static string FormatInline(string text)
{
var result = new StringBuilder();
var last = 0;
foreach (Match m in InlineCodeRegex().Matches(text))
{
result.Append(FormatEmphasis(text[last..m.Index]));
result.Append("<code>").Append(Escape(m.Groups[1].Value)).Append("</code>");
last = m.Index + m.Length;
}
result.Append(FormatEmphasis(text[last..]));
return result.ToString();
}
private static string FormatEmphasis(string text)
{
var s = Escape(text);
s = LinkRegex().Replace(s, m => $"<a href=\"{m.Groups[2].Value}\">{m.Groups[1].Value}</a>");
s = BoldRegex().Replace(s, "<b>$2</b>");
s = StrikeRegex().Replace(s, "<s>$1</s>");
s = ItalicRegex().Replace(s, "$1<i>$3</i>");
return s;
}
/// <summary>
/// Split raw Markdown into chunks of at most <paramref name="limit"/> characters, preferring paragraph, line and
/// word boundaries. A code fence cut by a split is closed at the end of one chunk and reopened in the next.
/// </summary>
public static IReadOnlyList<string> Split(string text, int limit = ChunkLimit)
{
if (text.Length <= limit) return [text];
var chunks = new List<string>();
var rest = text;
string? reopen = null;
while (rest.Length > 0)
{
var prefix = reopen is null ? "" : reopen + "\n";
var budget = limit - prefix.Length - (Fence.Length + 1); // room to close a fence
if (prefix.Length + rest.Length <= limit)
{
chunks.Add(prefix + rest);
break;
}
var (cut, skip) = FindCut(rest, budget);
var piece = rest[..cut];
rest = rest[(cut + skip)..].TrimStart('\n');
var openFence = OpenFence(prefix + piece);
var chunk = prefix + piece.TrimEnd('\n');
if (openFence is not null) chunk += "\n" + Fence;
chunks.Add(chunk);
reopen = openFence;
}
return chunks;
}
/// <returns>Where the chunk ends and how many separator characters to drop after it.</returns>
private static (int Cut, int Skip) FindCut(string text, int budget)
{
budget = Math.Max(1, Math.Min(budget, text.Length));
var window = text[..budget];
foreach (var separator in new[] { "\n\n", "\n", " " })
{
var at = window.LastIndexOf(separator, StringComparison.Ordinal);
if (at > budget / 2) return (at, separator.Length);
}
// Do not split a surrogate pair.
return (char.IsHighSurrogate(text[budget - 1]) ? budget - 1 : budget, 0);
}
/// <summary>The opening fence line (with language) when <paramref name="text"/> ends inside a code block.</summary>
private static string? OpenFence(string text)
{
string? open = null;
foreach (var line in text.Split('\n'))
{
var trimmed = line.TrimStart();
if (!trimmed.StartsWith(Fence, StringComparison.Ordinal)) continue;
open = open is null ? trimmed : null;
}
return open;
}
[GeneratedRegex(@"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$")]
private static partial Regex HeadingRegex();
[GeneratedRegex(@"^(\s*)[-*+]\s+(.*)$")]
private static partial Regex BulletRegex();
[GeneratedRegex(@"`([^`\n]+)`")]
private static partial Regex InlineCodeRegex();
[GeneratedRegex(@"\[([^\]\n]+)\]\((https?://[^\s)""]+)\)")]
private static partial Regex LinkRegex();
[GeneratedRegex(@"(\*\*|__)(?=\S)(.+?)(?<=\S)\1")]
private static partial Regex BoldRegex();
[GeneratedRegex(@"~~(?=\S)(.+?)(?<=\S)~~")]
private static partial Regex StrikeRegex();
[GeneratedRegex(@"(^|[^\w*])([*_])(?=\S)([^*_\n]+?)(?<=\S)\2(?![\w*])")]
private static partial Regex ItalicRegex();
}