forked from admin/VolcanicAgenticCore
188 lines
6.9 KiB
C#
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("&", "&").Replace("<", "<").Replace(">", ">");
|
|
|
|
/// <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();
|
|
}
|