< Summary

Information
Class: TeleFlow.Telegram.Formatting.TelegramTextBuilder
Assembly: TeleFlow.Telegram.Client
File(s): /_/src/TeleFlow.Telegram.Client/Formatting/TelegramTextBuilder.cs
Line coverage
80%
Covered lines: 53
Uncovered lines: 13
Coverable lines: 66
Total lines: 490
Line coverage: 80.3%
Branch coverage
64%
Covered branches: 9
Total branches: 14
Branch coverage: 64.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Text(...)100%11100%
LineBreak()100%11100%
Append(...)50%2266.66%
Bold(...)100%11100%
Bold(...)100%11100%
Italic(...)100%210%
Italic(...)100%210%
Underline(...)100%210%
Underline(...)100%210%
Strikethrough(...)100%210%
Strikethrough(...)100%210%
Spoiler(...)100%11100%
Spoiler(...)100%210%
Code(...)100%11100%
Pre(...)75%4480%
Link(...)50%2283.33%
Mention(...)50%2280%
BlockQuote(...)100%11100%
CustomEmoji(...)100%11100%
Build()100%22100%
AppendWrapped(...)100%11100%
AppendNested(...)50%2287.5%

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;
 2313    private readonly StringBuilder _content = new();
 14
 15    internal TelegramTextBuilder(TelegramTextRenderer renderer)
 16    {
 2317        ArgumentNullException.ThrowIfNull(renderer);
 2318        _renderer = renderer;
 2319    }
 20
 21    /// <summary>
 22    /// Appends plain text after escaping it for the selected parse mode.
 23    /// </summary>
 24    public TelegramTextBuilder Text(string text)
 25    {
 526        ArgumentNullException.ThrowIfNull(text);
 527        _content.Append(_renderer.EscapeText(text));
 528        return this;
 29    }
 30
 31    /// <summary>
 32    /// Appends a line break.
 33    /// </summary>
 34    public TelegramTextBuilder LineBreak()
 35    {
 136        _content.Append('\n');
 137        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    {
 145        ArgumentNullException.ThrowIfNull(text);
 46
 147        if (text.ParseMode != _renderer.ParseMode)
 48        {
 149            throw new InvalidOperationException(
 150                $"Formatted text with parse mode '{text.ParseMode}' cannot be appended to a builder using '{_renderer.Pa
 51        }
 52
 053        _content.Append(text.Text);
 054        return this;
 55    }
 56
 57    /// <summary>
 58    /// Appends bold text.
 59    /// </summary>
 60    public TelegramTextBuilder Bold(string text)
 61    {
 862        return AppendWrapped(text, _renderer.Bold);
 63    }
 64
 65    /// <summary>
 66    /// Appends bold nested content.
 67    /// </summary>
 68    public TelegramTextBuilder Bold(Action<TelegramTextBuilder> content)
 69    {
 170        return AppendNested(content, _renderer.Bold);
 71    }
 72
 73    /// <summary>
 74    /// Appends italic text.
 75    /// </summary>
 76    public TelegramTextBuilder Italic(string text)
 77    {
 078        return AppendWrapped(text, _renderer.Italic);
 79    }
 80
 81    /// <summary>
 82    /// Appends italic nested content.
 83    /// </summary>
 84    public TelegramTextBuilder Italic(Action<TelegramTextBuilder> content)
 85    {
 086        return AppendNested(content, _renderer.Italic);
 87    }
 88
 89    /// <summary>
 90    /// Appends underlined text.
 91    /// </summary>
 92    public TelegramTextBuilder Underline(string text)
 93    {
 094        return AppendWrapped(text, _renderer.Underline);
 95    }
 96
 97    /// <summary>
 98    /// Appends underlined nested content.
 99    /// </summary>
 100    public TelegramTextBuilder Underline(Action<TelegramTextBuilder> content)
 101    {
 0102        return AppendNested(content, _renderer.Underline);
 103    }
 104
 105    /// <summary>
 106    /// Appends strike-through text.
 107    /// </summary>
 108    public TelegramTextBuilder Strikethrough(string text)
 109    {
 0110        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    {
 0118        return AppendNested(content, _renderer.Strikethrough);
 119    }
 120
 121    /// <summary>
 122    /// Appends spoiler text.
 123    /// </summary>
 124    public TelegramTextBuilder Spoiler(string text)
 125    {
 1126        return AppendWrapped(text, _renderer.Spoiler);
 127    }
 128
 129    /// <summary>
 130    /// Appends spoiler nested content.
 131    /// </summary>
 132    public TelegramTextBuilder Spoiler(Action<TelegramTextBuilder> content)
 133    {
 0134        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    {
 1142        ArgumentException.ThrowIfNullOrEmpty(text);
 1143        _content.Append(_renderer.Code(text));
 1144        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    {
 4152        ArgumentException.ThrowIfNullOrEmpty(text);
 153
 4154        if (language is { Length: 0 })
 155        {
 0156            throw new ArgumentException("Preformatted language must be null or non-empty.", nameof(language));
 157        }
 158
 4159        _content.Append(_renderer.Pre(text, language));
 1160        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    {
 2168        ArgumentNullException.ThrowIfNull(uri);
 2169        ArgumentException.ThrowIfNullOrEmpty(label);
 170
 2171        if (!uri.IsAbsoluteUri)
 172        {
 0173            throw new ArgumentException("Telegram links must use an absolute URI.", nameof(uri));
 174        }
 175
 2176        _content.Append(_renderer.Link(uri.OriginalString, label));
 2177        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    {
 2185        if (userId <= 0)
 186        {
 0187            throw new ArgumentOutOfRangeException(nameof(userId), userId, "Telegram user id must be positive.");
 188        }
 189
 2190        ArgumentException.ThrowIfNullOrEmpty(label);
 2191        _content.Append(_renderer.Mention(userId, label));
 2192        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    {
 2200        ArgumentException.ThrowIfNullOrEmpty(text);
 2201        _content.Append(_renderer.BlockQuote(text, expandable));
 2202        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    {
 7211        ArgumentException.ThrowIfNullOrWhiteSpace(customEmojiId);
 5212        ArgumentException.ThrowIfNullOrWhiteSpace(fallbackEmoji);
 3213        _content.Append(_renderer.CustomEmoji(customEmojiId, fallbackEmoji));
 3214        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    {
 14222        var text = _content.ToString();
 223
 14224        if (text.Length == 0)
 225        {
 1226            throw new InvalidOperationException("Formatted text must not be empty.");
 227        }
 228
 13229        return new TelegramFormattedText(text, _renderer.ParseMode);
 230    }
 231
 232    private TelegramTextBuilder AppendWrapped(string text, Func<string, string> format)
 233    {
 9234        ArgumentException.ThrowIfNullOrEmpty(text);
 9235        _content.Append(format(_renderer.EscapeText(text)));
 9236        return this;
 237    }
 238
 239    private TelegramTextBuilder AppendNested(
 240        Action<TelegramTextBuilder> content,
 241        Func<string, string> format)
 242    {
 1243        ArgumentNullException.ThrowIfNull(content);
 244
 1245        var nested = new TelegramTextBuilder(_renderer);
 1246        content(nested);
 1247        var nestedText = nested._content.ToString();
 248
 1249        if (nestedText.Length == 0)
 250        {
 0251            throw new InvalidOperationException("Formatted nested content must not be empty.");
 252        }
 253
 1254        _content.Append(format(nestedText));
 1255        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
 378    public static TelegramMarkdownV2TextRenderer Instance { get; } = new();
 379
 380    public override TelegramParseMode ParseMode => TelegramParseMode.MarkdownV2;
 381
 382    public override string EscapeText(string text)
 383    {
 384        var builder = new StringBuilder(text.Length);
 385
 386        foreach (var character in text)
 387        {
 388            if (ReservedCharacters.Contains(character))
 389            {
 390                builder.Append('\\');
 391            }
 392
 393            builder.Append(character);
 394        }
 395
 396        return builder.ToString();
 397    }
 398
 399    public override string Bold(string text) => $"*{text}*";
 400
 401    public override string Italic(string text) => $"_{text}_";
 402
 403    public override string Underline(string text) => $"__{text}__";
 404
 405    public override string Strikethrough(string text) => $"~{text}~";
 406
 407    public override string Spoiler(string text) => $"||{text}||";
 408
 409    public override string Code(string text) => $"`{EscapeCode(text)}`";
 410
 411    public override string Pre(string text, string? language)
 412    {
 413        if (language is not null)
 414        {
 415            ValidateLanguage(language);
 416        }
 417
 418        return language is null
 419            ? $"```\n{EscapeCode(text)}\n```"
 420            : $"```{language}\n{EscapeCode(text)}\n```";
 421    }
 422
 423    public override string Link(string uri, string label)
 424    {
 425        return $"[{EscapeText(label)}]({EscapeLinkTarget(uri)})";
 426    }
 427
 428    public override string Mention(long userId, string label)
 429    {
 430        return Link($"tg://user?id={userId}", label);
 431    }
 432
 433    public override string BlockQuote(string text, bool expandable)
 434    {
 435        var lines = text
 436            .Replace("\r\n", "\n", StringComparison.Ordinal)
 437            .Replace('\r', '\n')
 438            .Split('\n');
 439        var builder = new StringBuilder(text.Length + lines.Length * 2);
 440
 441        for (var index = 0; index < lines.Length; index++)
 442        {
 443            builder.Append("> ");
 444            builder.Append(EscapeText(lines[index]));
 445
 446            if (index == lines.Length - 1 && expandable)
 447            {
 448                builder.Append("||");
 449            }
 450
 451            if (index < lines.Length - 1)
 452            {
 453                builder.Append('\n');
 454            }
 455        }
 456
 457        return builder.ToString();
 458    }
 459
 460    public override string CustomEmoji(string customEmojiId, string fallbackEmoji)
 461    {
 462        return $"![{EscapeText(fallbackEmoji)}](tg://emoji?id={EscapeLinkTarget(customEmojiId)})";
 463    }
 464
 465    private static string EscapeCode(string value)
 466    {
 467        return value
 468            .Replace("\\", "\\\\", StringComparison.Ordinal)
 469            .Replace("`", "\\`", StringComparison.Ordinal);
 470    }
 471
 472    private static string EscapeLinkTarget(string value)
 473    {
 474        return value
 475            .Replace("\\", "\\\\", StringComparison.Ordinal)
 476            .Replace(")", "\\)", StringComparison.Ordinal);
 477    }
 478
 479    private static void ValidateLanguage(string language)
 480    {
 481        if (language.Any(static character =>
 482                !char.IsAsciiLetterOrDigit(character) &&
 483                character is not '-' and not '_'))
 484        {
 485            throw new ArgumentException(
 486                "Preformatted language may contain only ASCII letters, digits, '-' and '_'.",
 487                nameof(language));
 488        }
 489    }
 490}