Repository: aiogram/aiogram
Pull request #1172 (2023-04-30 18:40:00)

Formatting tools
# Description

Added a tool to make text formatting flexible and easy

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes


github-actions[bot]: # :x: Changelog is required!

You need to add a brief description of the changes to the `CHANGES` directory.

For example, you can run `towncrier create <issue>.<type>` to create a file in the change directory and then write a description on that file.

Read more at [Towncrier docs](https://towncrier.readthedocs.io/en/latest/quickstart.html#creating-news-fragments)
codecov[bot]: ## [Codecov](https://codecov.io/gh/aiogram/aiogram/pull/1172?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) Report
> Merging [#1172](https://codecov.io/gh/aiogram/aiogram/pull/1172?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (bfa69f6) into [dev-3.x](https://codecov.io/gh/aiogram/aiogram/commit/cf269e15f4a098b051f97bc68bb2e710d3d61d8a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (cf269e1) will **decrease** coverage by `2.04%`.
> The diff coverage is `0.00%`.

[![Impacted file tree graph](https://codecov.io/gh/aiogram/aiogram/pull/1172/graphs/tree.svg?width=650&height=150&src=pr&token=cFqEr3p9MX&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)](https://codecov.io/gh/aiogram/aiogram/pull/1172?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)

```diff
@@             Coverage Diff             @@
##           dev-3.x    #1172      +/-   ##
===========================================
- Coverage   100.00%   97.96%   -2.04%     
===========================================
  Files          358      359       +1     
  Lines         8841     9025     +184     
===========================================
  Hits          8841     8841              
- Misses           0      184     +184     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `97.96% <0.00%> (-2.04%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/aiogram/aiogram/pull/1172?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [aiogram/utils/formatting.py](https://codecov.io/gh/aiogram/aiogram/pull/1172?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS91dGlscy9mb3JtYXR0aW5nLnB5) | `0.00% <0.00%> (ø)` | |


Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index d643b3f0..6b2da3b6 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,2 +1,2 @@
 open_collective: aiogram
-patreon: aiogram
+
diff --git a/CHANGES/1172.feature.rst b/CHANGES/1172.feature.rst
new file mode 100644
index 00000000..5a1005da
--- /dev/null
+++ b/CHANGES/1172.feature.rst
@@ -0,0 +1,2 @@
+Added a tool to make text formatting flexible and easy.
+More details on the :ref:`corresponding documentation page <formatting-tool>`
diff --git a/CHANGES/1176.bugfix.rst b/CHANGES/1176.bugfix.rst
new file mode 100644
index 00000000..57f67202
--- /dev/null
+++ b/CHANGES/1176.bugfix.rst
@@ -0,0 +1,1 @@
+Fixed the markdown spoiler parser.
diff --git a/aiogram/exceptions.py b/aiogram/exceptions.py
index 1c1e59fb..7ca7dcdd 100644
--- a/aiogram/exceptions.py
+++ b/aiogram/exceptions.py
@@ -34,6 +34,8 @@ class UnsupportedKeywordArgument(DetailedAiogramError):
 
 
 class TelegramAPIError(DetailedAiogramError):
+    label: str = "Telegram server says"
+
     def __init__(
         self,
         method: TelegramMethod[TelegramType],
@@ -44,11 +46,11 @@ class TelegramAPIError(DetailedAiogramError):
 
     def __str__(self) -> str:
         original_message = super().__str__()
-        return f"Telegram server says {original_message}"
+        return f"{self.label} - {original_message}"
 
 
 class TelegramNetworkError(TelegramAPIError):
-    pass
+    label = "HTTP Client says"
 
 
 class TelegramRetryAfter(TelegramAPIError):
diff --git a/aiogram/utils/formatting.py b/aiogram/utils/formatting.py
new file mode 100644
index 00000000..513d27fd
--- /dev/null
+++ b/aiogram/utils/formatting.py
@@ -0,0 +1,577 @@
+import textwrap
+from typing import (
+    Any,
+    ClassVar,
+    Dict,
+    Generator,
+    Iterable,
+    Iterator,
+    List,
+    Optional,
+    Tuple,
+    Type,
+)
+
+from typing_extensions import Self
+
+from aiogram.enums import MessageEntityType
+from aiogram.types import MessageEntity, User
+from aiogram.utils.text_decorations import (
+    add_surrogates,
+    html_decoration,
+    markdown_decoration,
+    remove_surrogates,
+)
+
+NodeType = Any
+
+
+def sizeof(value: str) -> int:
+    return len(value.encode("utf-16-le")) // 2
+
+
+class Text(Iterable[NodeType]):
+    """
+    Simple text element
+    """
+
+    type: ClassVar[Optional[str]] = None
+
+    __slots__ = ("_body", "_params")
+
+    def __init__(
+        self,
+        *body: NodeType,
+        **params: Any,
+    ) -> None:
+        self._body: Tuple[NodeType, ...] = body
+        self._params: Dict[str, Any] = params
+
+    @classmethod
+    def from_entities(cls, text: str, entities: List[MessageEntity]) -> "Text":
+        return cls(
+            *_unparse_entities(
+                text=add_surrogates(text),
+                entities=sorted(entities, key=lambda item: item.offset) if entities else [],
+            )
+        )
+
+    def render(
+        self,
+        *,
+        _offset: int = 0,
+        _sort: bool = True,
+        _collect_entities: bool = True,
+    ) -> Tuple[str, List[MessageEntity]]:
+        """
+        Render elements tree as text with entities list
+
+        :return:
+        """
+
+        text = ""
+        entities = []
+        offset = _offset
+
+        for node in self._body:
+            if not isinstance(node, Text):
+                node = str(node)
+                text += node
+                offset += sizeof(node)
+            else:
+                node_text, node_entities = node.render(
+                    _offset=offset,
+                    _sort=False,
+                    _collect_entities=_collect_entities,
+                )
+                text += node_text
+                offset += sizeof(node_text)
+                if _collect_entities:
+                    entities.extend(node_entities)
+
+        if _collect_entities and self.type:
+            entities.append(self._render_entity(offset=_offset, length=offset - _offset))
+
+        if _collect_entities and _sort:
+            entities.sort(key=lambda entity: entity.offset)
+
+        return text, entities
+
+    def _render_entity(self, *, offset: int, length: int) -> MessageEntity:
+        return MessageEntity(type=self.type, offset=offset, length=length, **self._params)
+
+    def as_kwargs(
+        self,
+        *,
+        text_key: str = "text",
+        entities_key: str = "entities",
+        replace_parse_mode: bool = True,
+        parse_mode_key: str = "parse_mode",
+    ) -> Dict[str, Any]:
+        """
+        Render elements tree as keyword arguments for usage in the API call, for example:
+
+        .. code-block:: python
+
+            entities = Text(...)
+            await message.answer(**entities.as_kwargs())
+
+        :param text_key:
+        :param entities_key:
+        :param replace_parse_mode:
+        :param parse_mode_key:
+        :return:
+        """
+        text_value, entities_value = self.render()
+        result: Dict[str, Any] = {
+            text_key: text_value,
+            entities_key: entities_value,
+        }
+        if replace_parse_mode:
+            result[parse_mode_key] = None
+        return result
+
+    def as_html(self) -> str:
+        """
+        Render elements tree as HTML markup
+        """
+        text, entities = self.render()
+        return html_decoration.unparse(text, entities)
+
+    def as_markdown(self) -> str:
+        """
+        Render elements tree as MarkdownV2 markup
+        """
+        text, entities = self.render()
+        return markdown_decoration.unparse(text, entities)
+
+    def replace(self: Self, *args: Any, **kwargs: Any) -> Self:
+        return type(self)(*args, **{**self._params, **kwargs})
+
+    def as_pretty_string(self, indent: bool = False) -> str:
+        sep = ",\n" if indent else ", "
+        body = sep.join(
+            item.as_pretty_string(indent=indent) if isinstance(item, Text) else repr(item)
+            for item in self._body
+        )
+        params = sep.join(f"{k}={v!r}" for k, v in self._params.items() if v is not None)
+
+        args = []
+        if body:
+            args.append(body)
+        if params:
+            args.append(params)
+
+        args_str = sep.join(args)
+        if indent:
+            args_str = textwrap.indent("\n" + args_str + "\n", "    ")
+        return f"{type(self).__name__}({args_str})"
+
+    def __add__(self, other: NodeType) -> "Text":
+        if isinstance(other, Text) and other.type == self.type and self._params == other._params:
+            return type(self)(*self, *other, **self._params)
+        if type(self) == Text and isinstance(other, str):
+            return type(self)(*self, other, **self._params)
+        return Text(self, other)
+
+    def __iter__(self) -> Iterator[NodeType]:
+        yield from self._body
+
+    def __len__(self) -> int:
+        text, _ = self.render(_collect_entities=False)
+        return sizeof(text)
+
+    def __getitem__(self, item: slice) -> "Text":
+        if not isinstance(item, slice):
+            raise TypeError("Can only be sliced")
+        if (item.start is None or item.start == 0) and item.stop is None:
+            return self.replace(*self._body)
+        start = 0 if item.start is None else item.start
+        stop = len(self) if item.stop is None else item.stop
+        if start == stop:
+            return self.replace()
+
+        nodes = []
+        position = 0
+
+        for node in self._body:
+            node_size = len(node)
+            current_position = position
+            position += node_size
+            if position < start:
+                continue
+            if current_position > stop:
+                break
+            a = max((0, start - current_position))
+            b = min((node_size, stop - current_position))
+            new_node = node[a:b]
+            if not new_node:
+                continue
+            nodes.append(new_node)
+
+        return self.replace(*nodes)
+
+
+class HashTag(Text):
+    """
+    Hashtag element.
+
+    .. warning::
+
+        The value should always start with '#' symbol
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.HASHTAG`
+    """
+
+    type = MessageEntityType.HASHTAG
+
+
+class CashTag(Text):
+    """
+    Cashtag element.
+
+    .. warning::
+
+        The value should always start with '$' symbol
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.CASHTAG`
+    """
+
+    type = MessageEntityType.CASHTAG
+
+
+class BotCommand(Text):
+    """
+    Bot command element.
+
+    .. warning::
+
+        The value should always start with '/' symbol
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.BOT_COMMAND`
+    """
+
+    type = MessageEntityType.BOT_COMMAND
+
+
+class Url(Text):
+    """
+    Url element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.URL`
+    """
+
+    type = MessageEntityType.URL
+
+
+class Email(Text):
+    """
+    Email element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.EMAIL`
+    """
+
+    type = MessageEntityType.EMAIL
+
+
+class PhoneNumber(Text):
+    """
+    Phone number element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.PHONE_NUMBER`
+    """
+
+    type = MessageEntityType.PHONE_NUMBER
+
+
+class Bold(Text):
+    """
+    Bold element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.BOLD`
+    """
+
+    type = MessageEntityType.BOLD
+
+
+class Italic(Text):
+    """
+    Italic element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.ITALIC`
+    """
+
+    type = MessageEntityType.ITALIC
+
+
+class Underline(Text):
+    """
+    Underline element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.UNDERLINE`
+    """
+
+    type = MessageEntityType.UNDERLINE
+
+
+class Strikethrough(Text):
+    """
+    Strikethrough element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.STRIKETHROUGH`
+    """
+
+    type = MessageEntityType.STRIKETHROUGH
+
+
+class Spoiler(Text):
+    """
+    Spoiler element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.SPOILER`
+    """
+
+    type = MessageEntityType.SPOILER
+
+
+class Code(Text):
+    """
+    Code element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.CODE`
+    """
+
+    type = MessageEntityType.CODE
+
+
+class Pre(Text):
+    """
+    Pre element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.PRE`
+    """
+
+    type = MessageEntityType.PRE
+
+    def __init__(self, *body: NodeType, language: Optional[str] = None, **params: Any) -> None:
+        super().__init__(*body, language=language, **params)
+
+
+class TextLink(Text):
+    """
+    Text link element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.TEXT_LINK`
+    """
+
+    type = MessageEntityType.TEXT_LINK
+
+    def __init__(self, *body: NodeType, url: str, **params: Any) -> None:
+        super().__init__(*body, url=url, **params)
+
+
+class TextMention(Text):
+    """
+    Text mention element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.TEXT_MENTION`
+    """
+
+    type = MessageEntityType.TEXT_MENTION
+
+    def __init__(self, *body: NodeType, user: User, **params: Any) -> None:
+        super().__init__(*body, user=user, **params)
+
+
+class CustomEmoji(Text):
+    """
+    Custom emoji element.
+
+    Will be wrapped into :obj:`aiogram.types.message_entity.MessageEntity`
+    with type :obj:`aiogram.enums.message_entity_type.MessageEntityType.CUSTOM_EMOJI`
+    """
+
+    type = MessageEntityType.CUSTOM_EMOJI
+
+    def __init__(self, *body: NodeType, custom_emoji_id: str, **params: Any) -> None:
+        super().__init__(*body, custom_emoji_id=custom_emoji_id, **params)
+
+
+NODE_TYPES: Dict[Optional[str], Type[Text]] = {
+    Text.type: Text,
+    HashTag.type: HashTag,
+    CashTag.type: CashTag,
+    BotCommand.type: BotCommand,
+    Url.type: Url,
+    Email.type: Email,
+    PhoneNumber.type: PhoneNumber,
+    Bold.type: Bold,
+    Italic.type: Italic,
+    Underline.type: Underline,
+    Strikethrough.type: Strikethrough,
+    Spoiler.type: Spoiler,
+    Code.type: Code,
+    Pre.type: Pre,
+    TextLink.type: TextLink,
+    TextMention.type: TextMention,
+}
+
+
+def _apply_entity(entity: MessageEntity, *nodes: NodeType) -> NodeType:
+    """
+    Apply single entity to text
+
+    :param entity:
+    :param text:
+    :return:
+    """
+    node_type = NODE_TYPES.get(entity.type, Text)
+    return node_type(*nodes, **entity.dict(exclude={"type", "offset", "length"}))
+
+
+def _unparse_entities(
+    text: bytes,
+    entities: List[MessageEntity],
+    offset: Optional[int] = None,
+    length: Optional[int] = None,
+) -> Generator[NodeType, None, None]:
+    if offset is None:
+        offset = 0
+    length = length or len(text)
+
+    for index, entity in enumerate(entities):
+        if entity.offset * 2 < offset:
+            continue
+        if entity.offset * 2 > offset:
+            yield remove_surrogates(text[offset : entity.offset * 2])
+        start = entity.offset * 2
+        offset = entity.offset * 2 + entity.length * 2
+
+        sub_entities = list(filter(lambda e: e.offset * 2 < (offset or 0), entities[index + 1 :]))
+        yield _apply_entity(
+            entity,
+            *_unparse_entities(text, sub_entities, offset=start, length=offset),
+        )
+
+    if offset < length:
+        yield remove_surrogates(text[offset:length])
+
+
+def as_line(*items: NodeType, end: str = "\n") -> Text:
+    """
+    Wrap multiple nodes into line with :code:`\\\\n` at the end of line.
+
+    :param items: Text or Any
+    :param end: ending of the line, by default is :code:`\\\\n`
+    :return: Text
+    """
+    return Text(*items, end)
+
+
+def as_list(*items: NodeType, sep: str = "\n") -> Text:
+    """
+    Wrap each element to separated lines
+
+    :param items:
+    :param sep:
+    :return:
+    """
+    nodes = []
+    for item in items[:-1]:
+        nodes.extend([item, sep])
+    nodes.append(items[-1])
+    return Text(*nodes)
+
+
+def as_marked_list(*items: NodeType, marker: str = "- ") -> Text:
+    """
+    Wrap elements as marked list
+
+    :param items:
+    :param marker: line marker, by default is :code:`- `
+    :return: Text
+    """
+    return as_list(*(Text(marker, item) for item in items))
+
+
+def as_numbered_list(*items: NodeType, start: int = 1, fmt: str = "{}. ") -> Text:
+    """
+    Wrap elements as numbered list
+
+    :param items:
+    :param start: initial number, by default 1
+    :param fmt: number format, by default :code:`{}. `
+    :return: Text
+    """
+    return as_list(*(Text(fmt.format(index), item) for index, item in enumerate(items, start)))
+
+
+def as_section(title: NodeType, *body: NodeType) -> Text:
+    """
+    Wrap elements as simple section, section has title and body
+
+    :param title:
+    :param body:
+    :return: Text
+    """
+    return Text(title, "\n", *body)
+
+
+def as_marked_section(
+    title: NodeType,
+    *body: NodeType,
+    marker: str = "- ",
+) -> Text:
+    """
+    Wrap elements as section with marked list
+
+    :param title:
+    :param body:
+    :param marker:
+    :return:
+    """
+    return as_section(title, as_marked_list(*body, marker=marker))
+
+
+def as_numbered_section(
+    title: NodeType,
+    *body: NodeType,
+    start: int = 1,
+    fmt: str = "{}. ",
+) -> Text:
+    """
+    Wrap elements as section with numbered list
+
+    :param title:
+    :param body:
+    :param start:
+    :param fmt:
+    :return:
+    """
+    return as_section(title, as_numbered_list(*body, start=start, fmt=fmt))
+
+
+def as_key_value(key: NodeType, value: NodeType) -> Text:
+    """
+    Wrap elements pair as key-value line. (:code:`<b>{key}:</b> {value}`)
+
+    :param key:
+    :param value:
+    :return: Text
+    """
+    return Text(Bold(key, ":"), " ", value)
diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py
index 70f96388..97430a32 100644
--- a/aiogram/utils/text_decorations.py
+++ b/aiogram/utils/text_decorations.py
@@ -233,7 +233,7 @@ class MarkdownDecoration(TextDecoration):
         return f"~{value}~"
 
     def spoiler(self, value: str) -> str:
-        return f"|{value}|"
+        return f"||{value}||"
 
     def quote(self, value: str) -> str:
         return re.sub(pattern=self.MARKDOWN_QUOTE_PATTERN, repl=r"\\\1", string=value)
diff --git a/docs/api/methods/set_sticker_set_thumb.rst b/docs/api/methods/set_sticker_set_thumb.rst
deleted file mode 100644
index 133fad94..00000000
--- a/docs/api/methods/set_sticker_set_thumb.rst
+++ /dev/null
@@ -1,44 +0,0 @@
-##################
-setStickerSetThumb
-##################
-
-Returns: :obj:`bool`
-
-.. automodule:: aiogram.methods.set_sticker_set_thumb
-    :members:
-    :member-order: bysource
-    :undoc-members: True
-
-
-Usage
-=====
-
-As bot method
--------------
-
-.. code-block::
-
-    result: bool = await bot.set_sticker_set_thumb(...)
-
-
-Method as object
-----------------
-
-Imports:
-
-- :code:`from aiogram.methods.set_sticker_set_thumb import SetStickerSetThumb`
-- alias: :code:`from aiogram.methods import SetStickerSetThumb`
-
-With specific bot
-~~~~~~~~~~~~~~~~~
-
-.. code-block:: python
-
-    result: bool = await bot(SetStickerSetThumb(...))
-
-As reply into Webhook in handler
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-.. code-block:: python
-
-    return SetStickerSetThumb(...)
diff --git a/docs/utils/formatting.rst b/docs/utils/formatting.rst
new file mode 100644
index 00000000..96d727f3
--- /dev/null
+++ b/docs/utils/formatting.rst
@@ -0,0 +1,199 @@
+.. _formatting-tool
+
+==========
+Formatting
+==========
+
+Make your message formatting flexible and simple
+
+This instrument works on top of Message entities instead of using HTML or Markdown markups,
+you can easily construct your message and sent it to the Telegram without the need to
+remember tag parity (opening and closing) or escaping user input.
+
+Usage
+=====
+
+Basic scenario
+--------------
+
+Construct your message and send it to the Telegram.
+
+.. code-block:: python
+
+    content = Text("Hello, ", Bold(message.from_user.full_name), "!")
+    await message.answer(**content.as_kwargs())
+
+Is the same as the next example, but without usage markup
+
+.. code-block:: python
+
+    await message.answer(
+        text=f"Hello, <b>{html.quote(message.from_user.full_name)}!",
+        parse_mode=ParseMode.HTML
+    )
+
+Literally when you execute :code:`as_kwargs` method the Text object is converted
+into text :code:`Hello, Alex!` with entities list :code:`[MessageEntity(type='bold', offset=7, length=4)]`
+and passed into dict which can be used as :code:`**kwargs` in API call.
+
+The complete list of elements is listed `on this page below <#available-elements>`_.
+
+Advanced scenario
+-----------------
+
+On top of base elements can be implemented content rendering structures,
+so, out of the box aiogram has a few already implemented functions that helps you to format
+your messages:
+
+.. autofunction:: aiogram.utils.formatting.as_line
+
+.. autofunction:: aiogram.utils.formatting.as_list
+
+.. autofunction:: aiogram.utils.formatting.as_marked_list
+
+.. autofunction:: aiogram.utils.formatting.as_numbered_list
+
+.. autofunction:: aiogram.utils.formatting.as_section
+
+.. autofunction:: aiogram.utils.formatting.as_marked_section
+
+.. autofunction:: aiogram.utils.formatting.as_numbered_section
+
+.. autofunction:: aiogram.utils.formatting.as_key_value
+
+and lets complete them all:
+
+.. code-block:: python
+
+    content = as_list(
+        as_marked_section(
+            Bold("Success:"),
+            "Test 1",
+            "Test 3",
+            "Test 4",
+            marker="✅ ",
+        ),
+        as_marked_section(
+            Bold("Failed:"),
+            "Test 2",
+            marker="❌ ",
+        ),
+        as_marked_section(
+            Bold("Summary:"),
+            as_key_value("Total", 4),
+            as_key_value("Success", 3),
+            as_key_value("Failed", 1),
+            marker="  ",
+        ),
+        HashTag("#test"),
+        sep="\n\n",
+    )
+
+Will be rendered into:
+
+  **Success:**
+
+  ✅ Test 1
+
+  ✅ Test 3
+
+  ✅ Test 4
+
+  **Failed:**
+
+  ❌ Test 2
+
+  **Summary:**
+
+   **Total**: 4
+
+   **Success**: 3
+
+   **Failed**: 1
+
+  #test
+
+
+Or as HTML:
+
+.. code-block:: html
+
+    <b>Success:</b>
+    ✅ Test 1
+    ✅ Test 3
+    ✅ Test 4
+
+    <b>Failed:</b>
+    ❌ Test 2
+
+    <b>Summary:</b>
+      <b>Total:</b> 4
+      <b>Success:</b> 3
+      <b>Failed:</b> 1
+
+    #test
+
+Available methods
+=================
+
+.. autoclass:: aiogram.utils.formatting.Text
+    :members:
+    :show-inheritance:
+    :member-order: bysource
+    :special-members: __init__
+
+
+Available elements
+==================
+
+.. autoclass:: aiogram.utils.formatting.Text
+    :show-inheritance:
+    :noindex:
+
+.. autoclass:: aiogram.utils.formatting.HashTag
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.CashTag
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.BotCommand
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.Url
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.Email
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.PhoneNumber
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.Bold
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.Italic
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.Underline
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.Strikethrough
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.Spoiler
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.Code
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.Pre
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.TextLink
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.TextMention
+    :show-inheritance:
+
+.. autoclass:: aiogram.utils.formatting.CustomEmoji
+    :show-inheritance:
diff --git a/docs/utils/index.rst b/docs/utils/index.rst
index cfe5a543..fbab2e4a 100644
--- a/docs/utils/index.rst
+++ b/docs/utils/index.rst
@@ -9,3 +9,4 @@ Utils
     chat_action
     web_app
     callback_answer
+    formatting


Test patch:
diff --git a/tests/test_utils/test_formatting.py b/tests/test_utils/test_formatting.py
new file mode 100644
index 00000000..5e14c4dc
--- /dev/null
+++ b/tests/test_utils/test_formatting.py
@@ -0,0 +1,319 @@
+import pytest
+
+from aiogram.enums import MessageEntityType
+from aiogram.types import MessageEntity, User
+from aiogram.utils.formatting import (
+    Bold,
+    BotCommand,
+    CashTag,
+    Code,
+    CustomEmoji,
+    Email,
+    HashTag,
+    Italic,
+    PhoneNumber,
+    Pre,
+    Spoiler,
+    Strikethrough,
+    Text,
+    TextLink,
+    TextMention,
+    Underline,
+    Url,
+    _apply_entity,
+    as_key_value,
+    as_line,
+    as_list,
+    as_marked_list,
+    as_marked_section,
+    as_numbered_list,
+    as_numbered_section,
+    as_section,
+)
+from aiogram.utils.text_decorations import html_decoration
+
+
+class TestNode:
+    @pytest.mark.parametrize(
+        "node,result",
+        [
+            [
+                Text("test"),
+                "test",
+            ],
+            [
+                HashTag("#test"),
+                "#test",
+            ],
+            [
+                CashTag("$TEST"),
+                "$TEST",
+            ],
+            [
+                BotCommand("/test"),
+                "/test",
+            ],
+            [
+                Url("https://example.com"),
+                "https://example.com",
+            ],
+            [
+                Email("test@example.com"),
+                "test@example.com",
+            ],
+            [
+                PhoneNumber("test"),
+                "test",
+            ],
+            [
+                Bold("test"),
+                "<b>test</b>",
+            ],
+            [
+                Italic("test"),
+                "<i>test</i>",
+            ],
+            [
+                Underline("test"),
+                "<u>test</u>",
+            ],
+            [
+                Strikethrough("test"),
+                "
test
",
+            ],
+            [
+                Spoiler("test"),
+                "<tg-spoiler>test</tg-spoiler>",
+            ],
+            [
+                Code("test"),
+                "<code>test</code>",
+            ],
+            [
+                Pre("test", language="python"),
+                '<pre><code class="language-python">test</code></pre>',
+            ],
+            [
+                TextLink("test", url="https://example.com"),
+                '<a href="https://example.com">test</a>',
+            ],
+            [
+                TextMention("test", user=User(id=42, is_bot=False, first_name="Test")),
+                '<a href="tg://user?id=42">test</a>',
+            ],
+            [
+                CustomEmoji("test", custom_emoji_id="42"),
+                '<tg-emoji emoji-id="42">test</tg-emoji>',
+            ],
+        ],
+    )
+    def test_render_plain_only(self, node: Text, result: str):
+        text, entities = node.render()
+        if node.type:
+            assert len(entities) == 1
+            entity = entities[0]
+            assert entity.type == node.type
+
+        content = html_decoration.unparse(text, entities)
+        assert content == result
+
+    def test_render_text(self):
+        node = Text("Hello, ", "World", "!")
+        text, entities = node.render()
+        assert text == "Hello, World!"
+        assert not entities
+
+    def test_render_nested(self):
+        node = Text(
+            Text("Hello, ", Bold("World"), "!"),
+            "\n",
+            Text(Bold("This ", Underline("is"), " test", Italic("!"))),
+            "\n",
+            HashTag("#test"),
+        )
+        text, entities = node.render()
+        assert text == "Hello, World!\nThis is test!\n#test"
+        assert entities == [
+            MessageEntity(type="bold", offset=7, length=5),
+            MessageEntity(type="bold", offset=14, length=13),
+            MessageEntity(type="underline", offset=19, length=2),
+            MessageEntity(type="italic", offset=26, length=1),
+            MessageEntity(type="hashtag", offset=28, length=5),
+        ]
+
+    def test_as_kwargs_default(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        result = node.as_kwargs()
+        assert "text" in result
+        assert "entities" in result
+        assert "parse_mode" in result
+
+    def test_as_kwargs_custom(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        result = node.as_kwargs(
+            text_key="caption",
+            entities_key="custom_entities",
+            parse_mode_key="custom_parse_mode",
+        )
+        assert "text" not in result
+        assert "caption" in result
+        assert "entities" not in result
+        assert "custom_entities" in result
+        assert "parse_mode" not in result
+        assert "custom_parse_mode" in result
+
+    def test_as_html(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        assert node.as_html() == "Hello, <b>World</b>!"
+
+    def test_as_markdown(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        assert node.as_markdown() == r"Hello, *World*\!"
+
+    def test_replace(self):
+        node0 = Text("test0", param0="test1")
+        node1 = node0.replace("test1", "test2", param1="test1")
+        assert node0._body != node1._body
+        assert node0._params != node1._params
+        assert "param1" not in node0._params
+        assert "param1" in node1._params
+
+    def test_add(self):
+        node0 = Text("Hello")
+        node1 = Bold("World")
+
+        node2 = node0 + Text(", ") + node1 + "!"
+        assert node0 != node2
+        assert node1 != node2
+        assert len(node0._body) == 1
+        assert len(node1._body) == 1
+        assert len(node2._body) == 3
+
+        text, entities = node2.render()
+        assert text == "Hello, World!"
+
+    def test_getitem_position(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        with pytest.raises(TypeError):
+            node[2]
+
+    def test_getitem_empty_slice(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        new_node = node[:]
+        assert new_node is not node
+        assert isinstance(new_node, Text)
+        assert new_node._body == node._body
+
+    def test_getitem_slice_zero(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        new_node = node[2:2]
+        assert node is not new_node
+        assert isinstance(new_node, Text)
+        assert not new_node._body
+
+    def test_getitem_slice_simple(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        new_node = node[2:10]
+        assert isinstance(new_node, Text)
+        text, entities = new_node.render()
+        assert text == "llo, Wor"
+        assert len(entities) == 1
+        assert entities[0].type == MessageEntityType.BOLD
+
+    def test_getitem_slice_inside_child(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        new_node = node[8:10]
+        assert isinstance(new_node, Text)
+        text, entities = new_node.render()
+        assert text == "or"
+        assert len(entities) == 1
+        assert entities[0].type == MessageEntityType.BOLD
+
+    def test_getitem_slice_tail(self):
+        node = Text("Hello, ", Bold("World"), "!")
+        new_node = node[12:13]
+        assert isinstance(new_node, Text)
+        text, entities = new_node.render()
+        assert text == "!"
+        assert not entities
+
+    def test_from_entities(self):
+        # Most of the cases covered by text_decorations module
+
+        node = Strikethrough.from_entities(
+            text="test1 test2 test3 test4 test5 test6 test7",
+            entities=[
+                MessageEntity(type="bold", offset=6, length=29),
+                MessageEntity(type="underline", offset=12, length=5),
+                MessageEntity(type="italic", offset=24, length=5),
+            ],
+        )
+        assert len(node._body) == 3
+        assert isinstance(node, Strikethrough)
+        rendered = node.as_html()
+        assert rendered == "
test1 <b>test2 <u>test3</u> test4 <i>test5</i> test6</b> test7
"
+
+    def test_pretty_string(self):
+        node = Strikethrough.from_entities(
+            text="X",
+            entities=[
+                MessageEntity(
+                    type=MessageEntityType.CUSTOM_EMOJI,
+                    offset=0,
+                    length=1,
+                    custom_emoji_id="42",
+                ),
+            ],
+        )
+        assert (
+            node.as_pretty_string(indent=True)
+            == """Strikethrough(
+    Text(
+        'X',
+        custom_emoji_id='42'
+    )
+)"""
+        )
+
+
+class TestUtils:
+    def test_apply_entity(self):
+        node = _apply_entity(
+            MessageEntity(type=MessageEntityType.BOLD, offset=0, length=4), "test"
+        )
+        assert isinstance(node, Bold)
+        assert node._body == ("test",)
+
+    def test_as_line(self):
+        node = as_line("test", "test", "test")
+        assert isinstance(node, Text)
+        assert len(node._body) == 4  # 3 + '\n'
+
+    def test_as_list(self):
+        node = as_list("test", "test", "test")
+        assert isinstance(node, Text)
+        assert len(node._body) == 5  # 3 + 2 * '\n' between lines
+
+    def test_as_marked_list(self):
+        node = as_marked_list("test 1", "test 2", "test 3")
+        assert node.as_html() == "- test 1\n- test 2\n- test 3"
+
+    def test_as_numbered_list(self):
+        node = as_numbered_list("test 1", "test 2", "test 3", start=5)
+        assert node.as_html() == "5. test 1\n6. test 2\n7. test 3"
+
+    def test_as_section(self):
+        node = as_section("title", "test 1", "test 2", "test 3")
+        assert node.as_html() == "title\ntest 1test 2test 3"
+
+    def test_as_marked_section(self):
+        node = as_marked_section("Section", "test 1", "test 2", "test 3")
+        assert node.as_html() == "Section\n- test 1\n- test 2\n- test 3"
+
+    def test_as_numbered_section(self):
+        node = as_numbered_section("Section", "test 1", "test 2", "test 3", start=5)
+        assert node.as_html() == "Section\n5. test 1\n6. test 2\n7. test 3"
+
+    def test_as_key_value(self):
+        node = as_key_value("key", "test 1")
+        assert node.as_html() == "<b>key:</b> test 1"
diff --git a/tests/test_utils/test_link.py b/tests/test_utils/test_link.py
index 77419441..f0276703 100644
--- a/tests/test_utils/test_link.py
+++ b/tests/test_utils/test_link.py
@@ -6,10 +6,10 @@ import pytest
 
 from aiogram.utils.link import (
     BRANCH,
+    create_channel_bot_link,
     create_telegram_link,
     create_tg_link,
     docs_url,
-    create_channel_bot_link,
 )
 
 
diff --git a/tests/test_utils/test_text_decorations.py b/tests/test_utils/test_text_decorations.py
index 01fbabb6..056bd1cb 100644
--- a/tests/test_utils/test_text_decorations.py
+++ b/tests/test_utils/test_text_decorations.py
@@ -90,7 +90,7 @@ class TestTextDecoration:
             [markdown_decoration, MessageEntity(type="bot_command", offset=0, length=5), "test"],
             [markdown_decoration, MessageEntity(type="email", offset=0, length=5), "test"],
             [markdown_decoration, MessageEntity(type="phone_number", offset=0, length=5), "test"],
-            [markdown_decoration, MessageEntity(type="spoiler", offset=0, length=5), "|test|"],
+            [markdown_decoration, MessageEntity(type="spoiler", offset=0, length=5), "||test||"],
             [
                 markdown_decoration,
                 MessageEntity(type="custom_emoji", offset=0, length=5, custom_emoji_id="42"),