< Summary

Information
Class: TeleFlow.Telegram.Formatting.TelegramMarkdownV2TextRenderer
Assembly: TeleFlow.Telegram.Client
File(s): /_/src/TeleFlow.Telegram.Client/Formatting/TelegramTextBuilder.cs
Line coverage
85%
Covered lines: 41
Uncovered lines: 7
Coverable lines: 48
Total lines: 490
Line coverage: 85.4%
Branch coverage
70%
Covered branches: 14
Total branches: 20
Branch coverage: 70%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
get_ParseMode()100%11100%
EscapeText(...)100%44100%
Bold(...)100%11100%
Italic(...)100%210%
Underline(...)100%210%
Strikethrough(...)100%210%
Spoiler(...)100%210%
Code(...)100%11100%
Pre(...)50%44100%
Link(...)100%11100%
Mention(...)100%11100%
BlockQuote(...)100%66100%
CustomEmoji(...)100%11100%
EscapeCode(...)100%11100%
EscapeLinkTarget(...)100%11100%
ValidateLanguage(...)33.33%9657.14%

File(s)

/_/src/TeleFlow.Telegram.Client/Formatting/TelegramTextBuilder.cs

#LineLine coverage
 1using System.Text;
 2
 3namespace TeleFlow.Telegram.Formatting;
 4
 5/// <summary>
 6/// Composes safe formatted text for one explicit Telegram Bot API parse mode.
 7/// The builder escapes plain values and keeps rendered fragments separate from
 8/// application strings, preventing accidental double escaping during composition.
 9/// </summary>
 10public sealed class TelegramTextBuilder
 11{
 12    private readonly TelegramTextRenderer _renderer;
 13    private readonly StringBuilder _content = new();
 14
 15    internal TelegramTextBuilder(TelegramTextRenderer renderer)
 16    {
 17        ArgumentNullException.ThrowIfNull(renderer);
 18        _renderer = renderer;
 19    }
 20
 21    /// <summary>
 22    /// Appends plain text after escaping it for the selected parse mode.
 23    /// </summary>
 24    public TelegramTextBuilder Text(string text)
 25    {
 26        ArgumentNullException.ThrowIfNull(text);
 27        _content.Append(_renderer.EscapeText(text));
 28        return this;
 29    }
 30
 31    /// <summary>
 32    /// Appends a line break.
 33    /// </summary>
 34    public TelegramTextBuilder LineBreak()
 35    {
 36        _content.Append('\n');
 37        return this;
 38    }
 39
 40    /// <summary>
 41    /// Appends a formatted value that uses the same parse mode as this builder.
 42    /// </summary>
 43    public TelegramTextBuilder Append(TelegramFormattedText text)
 44    {
 45        ArgumentNullException.ThrowIfNull(text);
 46
 47        if (text.ParseMode != _renderer.ParseMode)
 48        {
 49            throw new InvalidOperationException(
 50                $"Formatted text with parse mode '{text.ParseMode}' cannot be appended to a builder using '{_renderer.Pa
 51        }
 52
 53        _content.Append(text.Text);
 54        return this;
 55    }
 56
 57    /// <summary>
 58    /// Appends bold text.
 59    /// </summary>
 60    public TelegramTextBuilder Bold(string text)
 61    {
 62        return AppendWrapped(text, _renderer.Bold);
 63    }
 64
 65    /// <summary>
 66    /// Appends bold nested content.
 67    /// </summary>
 68    public TelegramTextBuilder Bold(Action<TelegramTextBuilder> content)
 69    {
 70        return AppendNested(content, _renderer.Bold);
 71    }
 72
 73    /// <summary>
 74    /// Appends italic text.
 75    /// </summary>
 76    public TelegramTextBuilder Italic(string text)
 77    {
 78        return AppendWrapped(text, _renderer.Italic);
 79    }
 80
 81    /// <summary>
 82    /// Appends italic nested content.
 83    /// </summary>
 84    public TelegramTextBuilder Italic(Action<TelegramTextBuilder> content)
 85    {
 86        return AppendNested(content, _renderer.Italic);
 87    }
 88
 89    /// <summary>
 90    /// Appends underlined text.
 91    /// </summary>
 92    public TelegramTextBuilder Underline(string text)
 93    {
 94        return AppendWrapped(text, _renderer.Underline);
 95    }
 96
 97    /// <summary>
 98    /// Appends underlined nested content.
 99    /// </summary>
 100    public TelegramTextBuilder Underline(Action<TelegramTextBuilder> content)
 101    {
 102        return AppendNested(content, _renderer.Underline);
 103    }
 104
 105    /// <summary>
 106    /// Appends strike-through text.
 107    /// </summary>
 108    public TelegramTextBuilder Strikethrough(string text)
 109    {
 110        return AppendWrapped(text, _renderer.Strikethrough);
 111    }
 112
 113    /// <summary>
 114    /// Appends strike-through nested content.
 115    /// </summary>
 116    public TelegramTextBuilder Strikethrough(Action<TelegramTextBuilder> content)
 117    {
 118        return AppendNested(content, _renderer.Strikethrough);
 119    }
 120
 121    /// <summary>
 122    /// Appends spoiler text.
 123    /// </summary>
 124    public TelegramTextBuilder Spoiler(string text)
 125    {
 126        return AppendWrapped(text, _renderer.Spoiler);
 127    }
 128
 129    /// <summary>
 130    /// Appends spoiler nested content.
 131    /// </summary>
 132    public TelegramTextBuilder Spoiler(Action<TelegramTextBuilder> content)
 133    {
 134        return AppendNested(content, _renderer.Spoiler);
 135    }
 136
 137    /// <summary>
 138    /// Appends inline code. Nested Telegram entities are intentionally not supported inside code.
 139    /// </summary>
 140    public TelegramTextBuilder Code(string text)
 141    {
 142        ArgumentException.ThrowIfNullOrEmpty(text);
 143        _content.Append(_renderer.Code(text));
 144        return this;
 145    }
 146
 147    /// <summary>
 148    /// Appends a preformatted block. Nested Telegram entities are intentionally not supported inside the block.
 149    /// </summary>
 150    public TelegramTextBuilder Pre(string text, string? language = null)
 151    {
 152        ArgumentException.ThrowIfNullOrEmpty(text);
 153
 154        if (language is { Length: 0 })
 155        {
 156            throw new ArgumentException("Preformatted language must be null or non-empty.", nameof(language));
 157        }
 158
 159        _content.Append(_renderer.Pre(text, language));
 160        return this;
 161    }
 162
 163    /// <summary>
 164    /// Appends a link with an escaped label and target.
 165    /// </summary>
 166    public TelegramTextBuilder Link(Uri uri, string label)
 167    {
 168        ArgumentNullException.ThrowIfNull(uri);
 169        ArgumentException.ThrowIfNullOrEmpty(label);
 170
 171        if (!uri.IsAbsoluteUri)
 172        {
 173            throw new ArgumentException("Telegram links must use an absolute URI.", nameof(uri));
 174        }
 175
 176        _content.Append(_renderer.Link(uri.OriginalString, label));
 177        return this;
 178    }
 179
 180    /// <summary>
 181    /// Appends a Telegram user mention with an escaped label.
 182    /// </summary>
 183    public TelegramTextBuilder Mention(long userId, string label)
 184    {
 185        if (userId <= 0)
 186        {
 187            throw new ArgumentOutOfRangeException(nameof(userId), userId, "Telegram user id must be positive.");
 188        }
 189
 190        ArgumentException.ThrowIfNullOrEmpty(label);
 191        _content.Append(_renderer.Mention(userId, label));
 192        return this;
 193    }
 194
 195    /// <summary>
 196    /// Appends a block quote. Nested entities are intentionally not supported inside the quote.
 197    /// </summary>
 198    public TelegramTextBuilder BlockQuote(string text, bool expandable = false)
 199    {
 200        ArgumentException.ThrowIfNullOrEmpty(text);
 201        _content.Append(_renderer.BlockQuote(text, expandable));
 202        return this;
 203    }
 204
 205    /// <summary>
 206    /// Appends a custom emoji with a required fallback emoji.
 207    /// Telegram may show the fallback in notifications, forwards, and unsupported clients.
 208    /// </summary>
 209    public TelegramTextBuilder CustomEmoji(string customEmojiId, string fallbackEmoji)
 210    {
 211        ArgumentException.ThrowIfNullOrWhiteSpace(customEmojiId);
 212        ArgumentException.ThrowIfNullOrWhiteSpace(fallbackEmoji);
 213        _content.Append(_renderer.CustomEmoji(customEmojiId, fallbackEmoji));
 214        return this;
 215    }
 216
 217    /// <summary>
 218    /// Builds an immutable value for framework helpers or explicit generated-client arguments.
 219    /// </summary>
 220    public TelegramFormattedText Build()
 221    {
 222        var text = _content.ToString();
 223
 224        if (text.Length == 0)
 225        {
 226            throw new InvalidOperationException("Formatted text must not be empty.");
 227        }
 228
 229        return new TelegramFormattedText(text, _renderer.ParseMode);
 230    }
 231
 232    private TelegramTextBuilder AppendWrapped(string text, Func<string, string> format)
 233    {
 234        ArgumentException.ThrowIfNullOrEmpty(text);
 235        _content.Append(format(_renderer.EscapeText(text)));
 236        return this;
 237    }
 238
 239    private TelegramTextBuilder AppendNested(
 240        Action<TelegramTextBuilder> content,
 241        Func<string, string> format)
 242    {
 243        ArgumentNullException.ThrowIfNull(content);
 244
 245        var nested = new TelegramTextBuilder(_renderer);
 246        content(nested);
 247        var nestedText = nested._content.ToString();
 248
 249        if (nestedText.Length == 0)
 250        {
 251            throw new InvalidOperationException("Formatted nested content must not be empty.");
 252        }
 253
 254        _content.Append(format(nestedText));
 255        return this;
 256    }
 257}
 258
 259/// <summary>
 260/// Renders the shared safe formatting vocabulary for one Telegram parse mode.
 261/// It is intentionally local to the client package and has no framework or transport dependency.
 262/// </summary>
 263internal abstract class TelegramTextRenderer
 264{
 265    public abstract TelegramParseMode ParseMode { get; }
 266
 267    public abstract string EscapeText(string text);
 268
 269    public abstract string Bold(string text);
 270
 271    public abstract string Italic(string text);
 272
 273    public abstract string Underline(string text);
 274
 275    public abstract string Strikethrough(string text);
 276
 277    public abstract string Spoiler(string text);
 278
 279    public abstract string Code(string text);
 280
 281    public abstract string Pre(string text, string? language);
 282
 283    public abstract string Link(string uri, string label);
 284
 285    public abstract string Mention(long userId, string label);
 286
 287    public abstract string BlockQuote(string text, bool expandable);
 288
 289    public abstract string CustomEmoji(string customEmojiId, string fallbackEmoji);
 290}
 291
 292/// <summary>
 293/// Renders safe Telegram HTML fragments for normal Bot API text messages.
 294/// </summary>
 295internal sealed class TelegramHtmlTextRenderer : TelegramTextRenderer
 296{
 297    public static TelegramHtmlTextRenderer Instance { get; } = new();
 298
 299    public override TelegramParseMode ParseMode => TelegramParseMode.Html;
 300
 301    public override string EscapeText(string text)
 302    {
 303        return TelegramHtmlEscaper.EscapeText(text);
 304    }
 305
 306    public override string Bold(string text) => $"<b>{text}</b>";
 307
 308    public override string Italic(string text) => $"<i>{text}</i>";
 309
 310    public override string Underline(string text) => $"<u>{text}</u>";
 311
 312    public override string Strikethrough(string text) => $"<s>{text}</s>";
 313
 314    public override string Spoiler(string text) => $"<tg-spoiler>{text}</tg-spoiler>";
 315
 316    public override string Code(string text) => $"<code>{EscapeText(text)}</code>";
 317
 318    public override string Pre(string text, string? language)
 319    {
 320        var escapedText = EscapeText(text);
 321
 322        if (language is null)
 323        {
 324            return $"<pre>{escapedText}</pre>";
 325        }
 326
 327        ValidateLanguage(language);
 328        return $"<pre><code class=\"language-{EscapeAttribute(language)}\">{escapedText}</code></pre>";
 329    }
 330
 331    public override string Link(string uri, string label)
 332    {
 333        return $"<a href=\"{EscapeAttribute(uri)}\">{EscapeText(label)}</a>";
 334    }
 335
 336    public override string Mention(long userId, string label)
 337    {
 338        return Link($"tg://user?id={userId}", label);
 339    }
 340
 341    public override string BlockQuote(string text, bool expandable)
 342    {
 343        return expandable
 344            ? $"<blockquote expandable>{EscapeText(text)}</blockquote>"
 345            : $"<blockquote>{EscapeText(text)}</blockquote>";
 346    }
 347
 348    public override string CustomEmoji(string customEmojiId, string fallbackEmoji)
 349    {
 350        return $"<tg-emoji emoji-id=\"{EscapeAttribute(customEmojiId)}\">{EscapeText(fallbackEmoji)}</tg-emoji>";
 351    }
 352
 353    private static string EscapeAttribute(string value)
 354    {
 355        return TelegramHtmlEscaper.EscapeInterpolation(value);
 356    }
 357
 358    private static void ValidateLanguage(string language)
 359    {
 360        if (language.Any(static character =>
 361                !char.IsAsciiLetterOrDigit(character) &&
 362                character is not '-' and not '_'))
 363        {
 364            throw new ArgumentException(
 365                "Preformatted language may contain only ASCII letters, digits, '-' and '_'.",
 366                nameof(language));
 367        }
 368    }
 369}
 370
 371/// <summary>
 372/// Renders safe Telegram MarkdownV2 fragments for normal Bot API text messages.
 373/// </summary>
 374internal sealed class TelegramMarkdownV2TextRenderer : TelegramTextRenderer
 375{
 376    private const string ReservedCharacters = "_*[]()~`>#+-=|{}.!\\";
 377
 1378    public static TelegramMarkdownV2TextRenderer Instance { get; } = new();
 379
 8380    public override TelegramParseMode ParseMode => TelegramParseMode.MarkdownV2;
 381
 382    public override string EscapeText(string text)
 383    {
 14384        var builder = new StringBuilder(text.Length);
 385
 262386        foreach (var character in text)
 387        {
 117388            if (ReservedCharacters.Contains(character))
 389            {
 24390                builder.Append('\\');
 391            }
 392
 117393            builder.Append(character);
 394        }
 395
 14396        return builder.ToString();
 397    }
 398
 4399    public override string Bold(string text) => $"*{text}*";
 400
 0401    public override string Italic(string text) => $"_{text}_";
 402
 0403    public override string Underline(string text) => $"__{text}__";
 404
 0405    public override string Strikethrough(string text) => $"~{text}~";
 406
 0407    public override string Spoiler(string text) => $"||{text}||";
 408
 1409    public override string Code(string text) => $"`{EscapeCode(text)}`";
 410
 411    public override string Pre(string text, string? language)
 412    {
 1413        if (language is not null)
 414        {
 1415            ValidateLanguage(language);
 416        }
 417
 1418        return language is null
 1419            ? $"```\n{EscapeCode(text)}\n```"
 1420            : $"```{language}\n{EscapeCode(text)}\n```";
 421    }
 422
 423    public override string Link(string uri, string label)
 424    {
 2425        return $"[{EscapeText(label)}]({EscapeLinkTarget(uri)})";
 426    }
 427
 428    public override string Mention(long userId, string label)
 429    {
 1430        return Link($"tg://user?id={userId}", label);
 431    }
 432
 433    public override string BlockQuote(string text, bool expandable)
 434    {
 1435        var lines = text
 1436            .Replace("\r\n", "\n", StringComparison.Ordinal)
 1437            .Replace('\r', '\n')
 1438            .Split('\n');
 1439        var builder = new StringBuilder(text.Length + lines.Length * 2);
 440
 6441        for (var index = 0; index < lines.Length; index++)
 442        {
 2443            builder.Append("> ");
 2444            builder.Append(EscapeText(lines[index]));
 445
 2446            if (index == lines.Length - 1 && expandable)
 447            {
 1448                builder.Append("||");
 449            }
 450
 2451            if (index < lines.Length - 1)
 452            {
 1453                builder.Append('\n');
 454            }
 455        }
 456
 1457        return builder.ToString();
 458    }
 459
 460    public override string CustomEmoji(string customEmojiId, string fallbackEmoji)
 461    {
 1462        return $"![{EscapeText(fallbackEmoji)}](tg://emoji?id={EscapeLinkTarget(customEmojiId)})";
 463    }
 464
 465    private static string EscapeCode(string value)
 466    {
 2467        return value
 2468            .Replace("\\", "\\\\", StringComparison.Ordinal)
 2469            .Replace("`", "\\`", StringComparison.Ordinal);
 470    }
 471
 472    private static string EscapeLinkTarget(string value)
 473    {
 3474        return value
 3475            .Replace("\\", "\\\\", StringComparison.Ordinal)
 3476            .Replace(")", "\\)", StringComparison.Ordinal);
 477    }
 478
 479    private static void ValidateLanguage(string language)
 480    {
 1481        if (language.Any(static character =>
 7482                !char.IsAsciiLetterOrDigit(character) &&
 7483                character is not '-' and not '_'))
 484        {
 0485            throw new ArgumentException(
 0486                "Preformatted language may contain only ASCII letters, digits, '-' and '_'.",
 0487                nameof(language));
 488        }
 1489    }
 490}