Source code for osmium_chat.content

"""Rich text formatting for outbound messages.

Formatting nodes — :class:`Bold`, :class:`Italic`, :class:`Underline`,
:class:`Strikethrough`, :class:`Code`, :class:`CodeBlock`, :class:`Spoiler`,
:class:`TextUrl`, and :class:`CustomEmoji <osmium_chat.emoji.CustomEmoji>` — can
be nested freely and embedded inside a :class:`Content` using either the
constructor or f-string interpolation::

    from osmium_chat.content import Bold, Content, Italic, UnicodeEmoji

    # constructor form
    msg = Content("Hello, ", Bold("world"), "!")

    # f-string form — identical result
    msg = Content(f"Hello, {Bold('world')}!")

    # nesting
    msg = Content(f"{Bold(Italic('important'))}")

    # unicode emoji (rendered as plain text)
    msg = Content("Congrats! ", UnicodeEmoji("🎉"))

Each node tree is serialized to a flat text string plus a matching list of
:class:`~osmium_protos.PB_MessageEntity` offset/length spans — the wire
format the Osmium gateway expects.
"""

from osmium_protos import (
    PB_MessageEntity,
    PB_MessageEntityPreEntity,
    PB_MessageEntitySpoilerEntity,
    PB_MessageEntityTextUrlEntity,
)

__all__: tuple[str, ...] = (
    "Bold",
    "Code",
    "CodeBlock",
    "Content",
    "Italic",
    "Spoiler",
    "Strikethrough",
    "TextUrl",
    "Underline",
    "UnicodeEmoji",
    "parse_content",
    "plain_text",
)

[docs] class UnicodeEmoji: """A standard Unicode emoji for use in :class:`Content`. When passed to :class:`Content`, it is treated as plain text — the emoji character is embedded in the message string without any formatting entity. .. code-block:: python Content("Congrats! ", UnicodeEmoji("🎉")) :param emoji: The Unicode emoji character(s). """ __slots__ = ("emoji",) def __init__(self, emoji: str) -> None: self.emoji: str = emoji def __str__(self) -> str: return self.emoji def __format__(self, _: str) -> str: return self.emoji def __repr__(self) -> str: return f"UnicodeEmoji({self.emoji!r})"
# Unicode Private Use Area sentinel used to embed nodes inside f-strings. # When __format__ is called on a node (e.g. f"{Bold('hi')}"), we register the # node here and return "{key}". __init__ then expands those back. _SENT = "" _pending: "dict[int, _FormattingNode]" = {} _seq = 0 def _register(node: "_FormattingNode") -> str: global _seq _seq += 1 _pending[_seq] = node return f"{_SENT}{_seq}{_SENT}" def _expand(parts: tuple) -> tuple: """Replace sentinel strings in *parts* with the registered node objects.""" out: list = [] for part in parts: if isinstance(part, UnicodeEmoji): out.append(part.emoji) elif isinstance(part, str) and _SENT in part: segments = part.split(_SENT) for i, seg in enumerate(segments): if i % 2 == 0: if seg: out.append(seg) else: try: out.append(_pending.pop(int(seg))) except (ValueError, KeyError): if seg: out.append(f"{_SENT}{seg}{_SENT}") else: out.append(part) return tuple(out) class _FormattingNode: __slots__ = ("_parts",) def __init__(self, *parts: "str | _FormattingNode") -> None: self._parts = _expand(parts) def __format__(self, _: str) -> str: return _register(self) def __str__(self) -> str: return _node_plain(self) def _make_entity(self, _start: int, _length: int) -> PB_MessageEntity: raise NotImplementedError def __repr__(self) -> str: return f"{type(self).__name__}({', '.join(repr(p) for p in self._parts)})"
[docs] class Bold(_FormattingNode): """Renders its contents in **bold**.""" __slots__ = () def _make_entity(self, start: int, length: int) -> PB_MessageEntity: return PB_MessageEntity(start_index=start, length=length, bold=True)
[docs] class Italic(_FormattingNode): """Renders its contents in *italic*.""" __slots__ = () def _make_entity(self, start: int, length: int) -> PB_MessageEntity: return PB_MessageEntity(start_index=start, length=length, italic=True)
[docs] class Underline(_FormattingNode): """Renders its contents with an underline.""" __slots__ = () def _make_entity(self, start: int, length: int) -> PB_MessageEntity: return PB_MessageEntity(start_index=start, length=length, underline=True)
[docs] class Strikethrough(_FormattingNode): """Renders its contents with a ~~strikethrough~~.""" __slots__ = () def _make_entity(self, start: int, length: int) -> PB_MessageEntity: return PB_MessageEntity(start_index=start, length=length, strikethrough=True)
[docs] class Code(_FormattingNode): """Renders its contents as inline ``code``.""" __slots__ = () def _make_entity(self, start: int, length: int) -> PB_MessageEntity: return PB_MessageEntity(start_index=start, length=length, code=True)
[docs] class CodeBlock(_FormattingNode): """Renders its contents as a fenced code block with optional syntax highlighting. .. code-block:: python CodeBlock("print('hello')", language="python") :param parts: The text content of the block. :param language: An optional language hint for syntax highlighting (e.g. ``"python"``). """ __slots__ = ("_language",) def __init__(self, *parts: "str | _FormattingNode", language: str = "") -> None: super().__init__(*parts) self._language = language def _make_entity(self, start: int, length: int) -> PB_MessageEntity: return PB_MessageEntity( start_index=start, length=length, pre=PB_MessageEntityPreEntity(language=self._language or None), ) def __repr__(self) -> str: lang = f", language={self._language!r}" if self._language else "" return f"CodeBlock({', '.join(repr(p) for p in self._parts)}{lang})"
[docs] class Spoiler(_FormattingNode): """Hides its contents behind a spoiler that must be clicked to reveal.""" __slots__ = () def _make_entity(self, start: int, length: int) -> PB_MessageEntity: return PB_MessageEntity( start_index=start, length=length, spoiler=PB_MessageEntitySpoilerEntity(), )
[docs] class TextUrl(_FormattingNode): """Renders its contents as a hyperlink. .. code-block:: python TextUrl("osmium.chat", url="https://osmium.chat") :param parts: The visible link text. :param url: The URL the link points to. """ __slots__ = ("_url",) def __init__(self, *parts: "str | _FormattingNode", url: str) -> None: super().__init__(*parts) self._url = url def _make_entity(self, start: int, length: int) -> PB_MessageEntity: return PB_MessageEntity( start_index=start, length=length, text_url=PB_MessageEntityTextUrlEntity(url=self._url), ) def __repr__(self) -> str: return f"TextUrl({', '.join(repr(p) for p in self._parts)}, url={self._url!r})"
[docs] class Content: """A complete message payload: plain text plus formatting entity spans. Construct by passing any mix of :class:`str` and formatting nodes:: content = Content("Price: ", Bold("$9.99"), " — limited time!") Or use f-string embedding:: content = Content(f"Price: {Bold('$9.99')} — limited time!") The :attr:`text` property returns the plain-text string; :attr:`entities` returns the matching ``PB_MessageEntity`` list ready for the wire. """ __slots__ = ("_parts", "_wire_entities") def __init__(self, *parts: "str | _FormattingNode | UnicodeEmoji") -> None: self._parts = _expand(parts) self._wire_entities: "list[PB_MessageEntity] | None" = None @property def text(self) -> str: return "".join(_node_plain(p) for p in self._parts) @property def entities(self) -> "list[PB_MessageEntity]": if self._wire_entities is not None: return self._wire_entities result: list[PB_MessageEntity] = [] _collect_entities(self._parts, 0, result) return result def __str__(self) -> str: return self.text def __repr__(self) -> str: return f"Content({', '.join(repr(p) for p in self._parts)})"
def _node_plain(node: "str | _FormattingNode") -> str: if isinstance(node, str): return node return "".join(_node_plain(p) for p in node._parts) def _utf16_len(s: str) -> int: """Return the number of UTF-16 code units in *s*. Osmium encodes entity offsets in UTF-16 code units. Characters outside the Basic Multilingual Plane (e.g. most emoji: 😀, 🎉) are a single Python ``str`` character but occupy *two* UTF-16 code units, so ``len()`` alone gives wrong offsets for any text containing such characters. """ return len(s.encode("utf-16-le")) >> 1 def _collect_entities( parts: tuple, offset: int, out: "list[PB_MessageEntity]", ) -> int: pos = offset for part in parts: if isinstance(part, str): pos += _utf16_len(part) else: plain_len = _utf16_len(_node_plain(part)) out.append(part._make_entity(pos, plain_len)) _collect_entities(part._parts, pos, out) pos += plain_len return pos
[docs] def parse_content( text: str, entities: "list[PB_MessageEntity] | None" = None, ) -> Content: """Build a :class:`Content` from a plain-text string and optional entity list.""" c = Content(text) c._wire_entities = list(entities) if entities else [] return c
[docs] def plain_text(content: Content) -> str: """Return the plain text of *content* with all formatting stripped.""" return content.text