diff --git a/docs/configuration/sinks/telegram.rst b/docs/configuration/sinks/telegram.rst index c25f54625..4842b16da 100644 --- a/docs/configuration/sinks/telegram.rst +++ b/docs/configuration/sinks/telegram.rst @@ -15,9 +15,13 @@ Robusta can report issues and events in your Kubernetes cluster to Telegram conv .. note:: - Tables are sent as file attachments to Telegram because it's too big for most Telegram chat clients. + Small tables (for example Alert labels) are included in the finding message as a + collapsible quote, including when ``send_files`` is ``False``. If a message is + longer than Telegram's 4096-character limit, it is split across sequential + messages rather than attached as a ``.txt`` file. ``send_files`` still attaches + images and other ``FileBlock`` files (graphs, logs), not table text. - In addition, 2-way interactivity (``CallbackBlock``) isn't implemented yet. + 2-way interactivity (``CallbackBlock``) isn't implemented yet. Getting your Bot token ------------------------------------------------ diff --git a/src/robusta/core/sinks/telegram/telegram_client.py b/src/robusta/core/sinks/telegram/telegram_client.py index fb23d34a4..d183734bc 100644 --- a/src/robusta/core/sinks/telegram/telegram_client.py +++ b/src/robusta/core/sinks/telegram/telegram_client.py @@ -5,6 +5,7 @@ import requests from robusta.core.reporting.utils import PNG_SUFFIX, SVG_SUFFIX, convert_svg_to_png, is_image +from robusta.core.sinks.telegram.telegram_html import split_telegram_html TELEGRAM_BASE_URL = os.environ.get("TELEGRAM_BASE_URL", "https://api.telegram.org") @@ -16,13 +17,24 @@ def __init__(self, chat_id: Union[int, str], thread_id: int, bot_token: str): self.bot_token = bot_token def send_message(self, message: str, disable_links_preview: bool = True): + """Send one or more HTML sendMessage calls. Oversized text is split, never attached as a file.""" + chunks = split_telegram_html(message) + if not chunks: + return + for chunk in chunks: + self._send_message_chunk(chunk, disable_links_preview=disable_links_preview) + + def _send_message_chunk(self, message: str, disable_links_preview: bool = True): url = f"{TELEGRAM_BASE_URL}/bot{self.bot_token}/sendMessage" message_json = { "chat_id": self.chat_id, "message_thread_id": self.thread_id, - "disable_web_page_preview": disable_links_preview, - "parse_mode": "Markdown", + # HTML is required for
. Do not use MarkdownV2 + # (open PR robusta-dev/robusta#2105 / issue #1982); that path cannot + # express expandable quotes. Related UX: #2137. + "parse_mode": "HTML", "text": message, + "link_preview_options": {"is_disabled": disable_links_preview}, } response = requests.post(url, json=message_json) @@ -33,13 +45,16 @@ def send_message(self, message: str, disable_links_preview: bool = True): def send_file(self, file_name: str, contents: bytes): file_type = "Photo" if is_image(file_name) else "Document" - url = f"{TELEGRAM_BASE_URL}/bot{self.bot_token}/send{file_type}?chat_id={self.chat_id}" + url = f"{TELEGRAM_BASE_URL}/bot{self.bot_token}/send{file_type}" if file_name.endswith(SVG_SUFFIX): contents = convert_svg_to_png(contents) file_name = file_name.replace(SVG_SUFFIX, PNG_SUFFIX) + data = {"chat_id": self.chat_id} + if self.thread_id is not None: + data["message_thread_id"] = self.thread_id files = {file_type.lower(): (file_name, contents)} - response = requests.post(url, files=files) + response = requests.post(url, data=data, files=files) if response.status_code != 200: logging.error( diff --git a/src/robusta/core/sinks/telegram/telegram_html.py b/src/robusta/core/sinks/telegram/telegram_html.py new file mode 100644 index 000000000..50ef5ac7b --- /dev/null +++ b/src/robusta/core/sinks/telegram/telegram_html.py @@ -0,0 +1,245 @@ +"""Telegram HTML helpers. + +Issue #2167 requires parse_mode=HTML and
. This module is +intentionally separate from any MarkdownV2 transformer (PR #2105 / issue #1982). +Related Telegram UX: #2137. Tables are never routed through +Transformer.tableblock_to_fileblocks (column-count helper used by other sinks; +telegram historically filed every table after PR #245). +""" + +import html +import re +from typing import List + +from robusta.core.reporting.base import BaseBlock +from robusta.core.reporting.blocks import ( + DividerBlock, + FileBlock, + HeaderBlock, + JsonBlock, + KubernetesDiffBlock, + ListBlock, + MarkdownBlock, + TableBlock, +) + +try: + from tabulate import tabulate +except ImportError: + + def tabulate(*args, **kwargs): + raise ImportError("Please install tabulate to use the TableBlock") + + +TELEGRAM_MESSAGE_CHAR_LIMIT = 4096 +TELEGRAM_MIN_DOCUMENT_TEXT_BYTES = 1024 +# Room to close/reopen a nested tag stack when splitting a long message. +_TAG_CLOSE_RESERVE = 80 + +_TAG_PATTERN = re.compile(r"]*)?/?>") +_SLACK_LINK_PATTERN = re.compile(r"<([^<|>\s]+)\|([^>]+)>") +_MD_LINK_PATTERN = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +_CODE_PATTERN = re.compile(r"`([^`]+)`") +_BOLD_DOUBLE_PATTERN = re.compile(r"\*\*(.+?)\*\*") +_BOLD_SINGLE_PATTERN = re.compile(r"\*(?!\s)([^*]+?)\*") + + +def escape_telegram_html(text: str) -> str: + """Escape &, <, and > in user or table text for Telegram HTML parse_mode.""" + return html.escape(str(text), quote=False) + + +def telegram_html_link(text: str, url: str) -> str: + """Build an tag with escaped text and URL.""" + return f'{escape_telegram_html(text)}' + + +def markdown_to_telegram_html(text: str) -> str: + """Convert finding markdown (including Slack links) to Telegram HTML. + + User text is HTML-escaped first. Only Telegram-supported tags are emitted. + """ + if not text: + return "" + + slack_links = [] + + def _stash_slack_link(match: re.Match) -> str: + slack_links.append((match.group(2), match.group(1))) + return f"\x00SLACK{len(slack_links) - 1}\x00" + + working = _SLACK_LINK_PATTERN.sub(_stash_slack_link, text) + escaped = escape_telegram_html(working) + + md_links = [] + + def _stash_md_link(match: re.Match) -> str: + md_links.append((match.group(1), match.group(2))) + return f"\x00MDLINK{len(md_links) - 1}\x00" + + escaped = _MD_LINK_PATTERN.sub(_stash_md_link, escaped) + + code_spans = [] + + def _stash_code(match: re.Match) -> str: + code_spans.append(match.group(1)) + return f"\x00CODE{len(code_spans) - 1}\x00" + + escaped = _CODE_PATTERN.sub(_stash_code, escaped) + escaped = _BOLD_DOUBLE_PATTERN.sub(r"\1", escaped) + escaped = _BOLD_SINGLE_PATTERN.sub(r"\1", escaped) + + for index, code_text in enumerate(code_spans): + escaped = escaped.replace(f"\x00CODE{index}\x00", f"{code_text}") + + for index, (link_text, url) in enumerate(md_links): + # link_text and url were escaped with quote=False; re-escape the href. + href = html.escape(html.unescape(url), quote=True) + escaped = escaped.replace(f"\x00MDLINK{index}\x00", f'{link_text}') + + for index, (link_text, url) in enumerate(slack_links): + escaped = escaped.replace( + f"\x00SLACK{index}\x00", + telegram_html_link(link_text, url), + ) + + return escaped + + +def table_block_to_telegram_html(block: TableBlock) -> str: + """Render a TableBlock as a collapsible Telegram quote.""" + table_text = tabulate(block.render_rows(), headers=block.headers, tablefmt="presto") + body = f"
{escape_telegram_html(table_text)}
" + if block.table_name: + body = f"{markdown_to_telegram_html(block.table_name)}\n{body}" + return f"
{body}
" + + +def block_to_telegram_html(block: BaseBlock) -> str: + """Render a reporting block as Telegram HTML. FileBlocks are omitted.""" + if isinstance(block, FileBlock): + return "" + if isinstance(block, TableBlock): + return table_block_to_telegram_html(block) + if isinstance(block, MarkdownBlock): + return markdown_to_telegram_html(block.text) if block.text else "" + if isinstance(block, DividerBlock): + return "-------------------" + if isinstance(block, JsonBlock): + return f"
{escape_telegram_html(block.json_str)}
" + if isinstance(block, HeaderBlock): + return f"{escape_telegram_html(block.text)}" + if isinstance(block, ListBlock): + return "\n".join(f"• {escape_telegram_html(item)}" for item in block.items) + if isinstance(block, KubernetesDiffBlock): + lines = [] + for diff in block.diffs: + path = escape_telegram_html(".".join(str(part) for part in diff.path)) + old = escape_telegram_html(str(diff.other_value)) + new = escape_telegram_html(str(diff.value)) + lines.append(f"{path}: {old} ==> {new}") + return "\n".join(lines) + return "" + + +def _tag_name(opening_tag: str) -> str: + match = re.match(r" List[str]: + """Return unmatched opening tags in document order (full tag strings).""" + stack: List[str] = [] + for match in _TAG_PATTERN.finditer(html_text): + raw = match.group(0) + name = match.group(1).lower() + if raw.startswith(""): + continue + else: + stack.append(raw) + return stack + + +def _avoid_split_inside_entity(text: str, index: int) -> int: + """Backtrack so `&` / `<` / `{` are not split across chunks.""" + if index <= 0 or index >= len(text): + return index + amp = text.rfind("&", 0, index) + if amp == -1: + return index + semicolon = text.find(";", amp, min(len(text), amp + 16)) + if semicolon == -1: + return amp + if amp < index <= semicolon: + return amp + return index + + +def _find_split_index(text: str, limit: int) -> int: + """Choose a split index at or before limit that is not inside a tag or entity.""" + if len(text) <= limit: + return len(text) + window = text[:limit] + last_open = window.rfind("<") + last_close = window.rfind(">") + if last_open > last_close: + return _avoid_split_inside_entity(text, last_open if last_open > 0 else limit) + + newline = window.rfind("\n") + if newline >= limit // 4: + return _avoid_split_inside_entity(text, newline + 1) + space = window.rfind(" ") + if space >= limit // 4: + return _avoid_split_inside_entity(text, space + 1) + return _avoid_split_inside_entity(text, limit) + + +def split_telegram_html(text: str, limit: int = TELEGRAM_MESSAGE_CHAR_LIMIT) -> List[str]: + """Split HTML into sequential chunks that each fit Telegram's sendMessage limit. + + Unclosed tags are closed at the end of a chunk and reopened on the next one. + Never used as a reason to send a .txt document. + """ + if not text: + return [] + + chunks: List[str] = [] + remaining = text + while remaining: + if len(remaining) <= limit: + chunks.append(remaining) + break + + content_limit = max(limit - _TAG_CLOSE_RESERVE, 1) + split_at = _find_split_index(remaining, content_limit) + if split_at <= 0: + split_at = min(content_limit, len(remaining)) + + chunk = remaining[:split_at] + remaining = remaining[split_at:] + stack = _open_tag_stack(chunk) + if stack: + close = "".join(f"" for tag in reversed(stack)) + reopen = "".join(stack) + while chunk and len(chunk) + len(close) > limit: + remaining = chunk[-1] + remaining + chunk = chunk[:-1] + stack = _open_tag_stack(chunk) + close = "".join(f"" for tag in reversed(stack)) + reopen = "".join(stack) + chunk = chunk + close + remaining = reopen + remaining + if chunk: + chunks.append(chunk) + + return chunks + + +def should_send_text_as_document(contents: bytes) -> bool: + """Return True only when text is large enough to justify sendDocument.""" + return len(contents) >= TELEGRAM_MIN_DOCUMENT_TEXT_BYTES diff --git a/src/robusta/core/sinks/telegram/telegram_sink.py b/src/robusta/core/sinks/telegram/telegram_sink.py index fd5a10c1e..57f48f9c4 100644 --- a/src/robusta/core/sinks/telegram/telegram_sink.py +++ b/src/robusta/core/sinks/telegram/telegram_sink.py @@ -1,13 +1,15 @@ -from enum import Enum - -from tabulate import tabulate - from robusta.core.reporting.base import BaseBlock, Finding, FindingSeverity, FindingStatus -from robusta.core.reporting.blocks import FileBlock, MarkdownBlock, TableBlock +from robusta.core.reporting.blocks import FileBlock +from robusta.core.reporting.utils import is_image from robusta.core.sinks.sink_base import SinkBase from robusta.core.sinks.telegram.telegram_client import TelegramClient +from robusta.core.sinks.telegram.telegram_html import ( + block_to_telegram_html, + escape_telegram_html, + markdown_to_telegram_html, + telegram_html_link, +) from robusta.core.sinks.telegram.telegram_sink_params import TelegramSinkConfigWrapper -from robusta.core.sinks.transformer import Transformer SEVERITY_EMOJI_MAP = { FindingSeverity.INFO: "\U0001F7E2", @@ -16,7 +18,6 @@ } INVESTIGATE_ICON = "\U0001F50E" SILENCE_ICON = "\U0001F515" -VIDEO_ICON = "\U0001F3AC" class TelegramSink(SinkBase): @@ -32,17 +33,20 @@ def write_finding(self, finding: Finding, platform_enabled: bool): self.__send_telegram_message(finding, platform_enabled) def __send_telegram_message(self, finding: Finding, platform_enabled: bool): - self.client.send_message(self.__get_message_text(finding, platform_enabled)) - if self.send_files: - for enrichment in finding.enrichments: - file_blocks = [block for block in enrichment.blocks if isinstance(block, FileBlock)] - for block in file_blocks: + has_graph_or_image = self.send_files and self.__finding_has_graph_or_image(finding) + self.client.send_message( + self.__get_message_text(finding, platform_enabled), + disable_links_preview=not has_graph_or_image, + ) + # Tables are already in the HTML message. send_files only attaches real + # FileBlock images/files. When send_files is false, tables still inline + # (or split across sendMessage); they are never dropped or sent as .txt. + if not self.send_files: + return + for enrichment in finding.enrichments: + for block in enrichment.blocks: + if isinstance(block, FileBlock): self.client.send_file(file_name=block.filename, contents=block.contents) - table_blocks = [block for block in enrichment.blocks if isinstance(block, TableBlock)] - for block in table_blocks: - table_text = tabulate(block.render_rows(), headers=block.headers, tablefmt="presto") - table_name = block.table_name if block.table_name else "table" - self.client.send_file(file_name=f"{table_name}.txt", contents=table_text.encode("utf-8")) def __get_message_text(self, finding: Finding, platform_enabled: bool): status: FindingStatus = ( @@ -56,44 +60,57 @@ def __get_message_text(self, finding: Finding, platform_enabled: bool): if actions_content: message_content += actions_content - blocks = [MarkdownBlock(text=f"*Source:* `{self.cluster_name}`\n\n")] + message_content += f"Source: {escape_telegram_html(self.cluster_name)}\n\n" - # first add finding description block if finding.description: - blocks.append(MarkdownBlock(finding.description)) + message_content += markdown_to_telegram_html(finding.description) + "\n" for enrichment in finding.enrichments: - blocks.extend([block for block in enrichment.blocks if self.__is_telegram_text_block(block)]) - - for block in blocks: - block_text = Transformer.to_standard_markdown([block]) - if len(block_text) + len(message_content) >= 4096: # telegram message size limit - break - message_content += block_text + "\n" + for block in enrichment.blocks: + if not self.__is_telegram_text_block(block): + continue + block_text = block_to_telegram_html(block) + if block_text: + message_content += block_text + "\n" return message_content def _get_actions_block(self, finding: Finding, platform_enabled: bool): - actions_content = "" + actions = [] if platform_enabled: - actions_content += ( - f"[{INVESTIGATE_ICON} Investigate]({finding.get_investigate_uri(self.account_id, self.cluster_name)}) " + actions.append( + telegram_html_link( + f"{INVESTIGATE_ICON} Investigate", + finding.get_investigate_uri(self.account_id, self.cluster_name), + ) ) if finding.add_silence_url: - actions_content += f"[{SILENCE_ICON} Silence]({finding.get_prometheus_silence_url(self.account_id, self.cluster_name)})" + actions.append( + telegram_html_link( + f"{SILENCE_ICON} Silence", + finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + ) + ) for link in finding.links: - actions_content = f"[{link.link_text}]({link.url})" + actions.append(telegram_html_link(link.link_text, link.url)) - if actions_content: - actions_content += "\n\n" + if not actions: + return "" - return actions_content + return " ".join(actions) + "\n\n" @classmethod def __is_telegram_text_block(cls, block: BaseBlock) -> bool: - # enrichments text tables are too big for mobile device - return not (isinstance(block, FileBlock) or isinstance(block, TableBlock)) + return not isinstance(block, FileBlock) + + @classmethod + def __finding_has_graph_or_image(cls, finding: Finding) -> bool: + for enrichment in finding.enrichments: + for block in enrichment.blocks: + if isinstance(block, FileBlock) and is_image(block.filename): + return True + return False @classmethod def __build_telegram_title( @@ -101,4 +118,4 @@ def __build_telegram_title( ) -> str: icon = SEVERITY_EMOJI_MAP.get(severity, "") status_str: str = f"{status.to_emoji()} {status.name.lower()} - " if add_silence_url else "" - return f"{status_str}{icon} {severity.name} - *{title}*\n\n" + return f"{status_str}{icon} {severity.name} - {escape_telegram_html(title)}\n\n" diff --git a/src/robusta/core/sinks/telegram/telegram_sink_params.py b/src/robusta/core/sinks/telegram/telegram_sink_params.py index cf1d002b0..f435613db 100644 --- a/src/robusta/core/sinks/telegram/telegram_sink_params.py +++ b/src/robusta/core/sinks/telegram/telegram_sink_params.py @@ -8,7 +8,7 @@ class TelegramSinkParams(SinkBaseParams): bot_token: str chat_id: Union[int, str] thread_id: int = None - send_files: bool = True # Change to False, to omit file attachments + send_files: bool = True # Images and FileBlock files only; tables are always inline @classmethod def _get_sink_type(cls): diff --git a/tests/test_telegram_sink.py b/tests/test_telegram_sink.py new file mode 100644 index 000000000..631c67a2b --- /dev/null +++ b/tests/test_telegram_sink.py @@ -0,0 +1,400 @@ +from unittest.mock import MagicMock, patch + +from robusta.core.reporting.base import Finding, FindingSeverity, Link +from robusta.core.reporting.blocks import FileBlock, MarkdownBlock, TableBlock +from robusta.core.sinks.telegram.telegram_client import TelegramClient +from robusta.core.sinks.telegram.telegram_html import ( + TELEGRAM_MESSAGE_CHAR_LIMIT, + TELEGRAM_MIN_DOCUMENT_TEXT_BYTES, + escape_telegram_html, + markdown_to_telegram_html, + should_send_text_as_document, + split_telegram_html, + table_block_to_telegram_html, +) +from robusta.core.sinks.telegram.telegram_sink import TelegramSink +from robusta.core.sinks.telegram.telegram_sink_params import TelegramSinkConfigWrapper, TelegramSinkParams + + +class MockRegistry: + def get_global_config(self) -> dict: + return { + "account_id": "test-account", + "cluster_name": "test-cluster", + "signing_key": "test-signing-key", + } + + +def _sink(send_files: bool = True) -> TelegramSink: + config = TelegramSinkConfigWrapper( + telegram_sink=TelegramSinkParams( + name="telegram_sink", + bot_token="test-token", + chat_id=123456, + thread_id=1, + send_files=send_files, + ) + ) + return TelegramSink(config, MockRegistry()) + + +def _finding_with_table(table_name: str = "*Alert labels*", rows=None, headers=None, description="Pod is crashing"): + finding = Finding( + title="CrashLoopBackOff", + description=description, + aggregation_key="CrashLoopBackOff", + severity=FindingSeverity.HIGH, + ) + finding.add_enrichment( + [ + TableBlock( + rows=rows + or [ + ["alertname", "CrashLoopBackOff"], + ["namespace", "default"], + ["pod", "demo"], + ], + headers=headers or ["label", "value"], + table_name=table_name, + ) + ] + ) + return finding + + +def test_escape_telegram_html_escapes_ampersand_lt_gt(): + assert escape_telegram_html("a & b < c > d") == "a & b < c > d" + + +def test_markdown_to_telegram_html_escapes_user_text_and_applies_formatting(): + html = markdown_to_telegram_html("See *status* of `app & svc` at [docs](https://example.com/?q=a&b=1)") + assert "status" in html + assert "app & svc" in html + assert 'href="https://example.com/?q=a&b=1"' in html + assert "&" in html + assert "