From 1fa28112ab4b1fc6889a1d7bd1955bade21aba2b Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:16:25 -0700 Subject: [PATCH 01/12] feat(telegram): add MarkdownV2 escape helpers (#1982) Co-Authored-By: Claude Opus 4.8 --- .../sinks/telegram/telegram_transformer.py | 14 +++++++++++ tests/test_telegram_transformer.py | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/robusta/core/sinks/telegram/telegram_transformer.py create mode 100644 tests/test_telegram_transformer.py diff --git a/src/robusta/core/sinks/telegram/telegram_transformer.py b/src/robusta/core/sinks/telegram/telegram_transformer.py new file mode 100644 index 000000000..1fde9dba9 --- /dev/null +++ b/src/robusta/core/sinks/telegram/telegram_transformer.py @@ -0,0 +1,14 @@ +import re + +_MARKDOWNV2_SPECIAL_CHARS = r"_*[]()~`>#+-=|{}.!" +_MARKDOWNV2_ESCAPE_RE = re.compile("([" + re.escape(_MARKDOWNV2_SPECIAL_CHARS) + "])") + + +def escape_markdownv2(text: str) -> str: + """Escape all Telegram MarkdownV2 special characters in arbitrary text.""" + return _MARKDOWNV2_ESCAPE_RE.sub(r"\\\1", text) + + +def escape_markdownv2_code(text: str) -> str: + """Escape content placed inside a MarkdownV2 code/pre span (only ` and \\).""" + return text.replace("\\", "\\\\").replace("`", "\\`") diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py new file mode 100644 index 000000000..9fbac32e2 --- /dev/null +++ b/tests/test_telegram_transformer.py @@ -0,0 +1,23 @@ +from robusta.core.sinks.telegram.telegram_transformer import ( + escape_markdownv2, + escape_markdownv2_code, +) + + +def test_escape_markdownv2_underscore_pod_name(): + # the exact repro from issue #1982 + assert escape_markdownv2("crowdsec-agent_k8vkt") == r"crowdsec\-agent\_k8vkt" + + +def test_escape_markdownv2_all_special_chars(): + assert escape_markdownv2("_*[]()~`>#+-=|{}.!") == ( + r"\_\*\[\]\(\)\~\`\>\#\+\-\=\|\{\}\.\!" + ) + + +def test_escape_markdownv2_plain_text_unchanged(): + assert escape_markdownv2("hello world 123") == "hello world 123" + + +def test_escape_markdownv2_code_only_backtick_and_backslash(): + assert escape_markdownv2_code(r"a`b\c_d*e") == r"a\`b\\c_d*e" From 7caaf30e92bbbd9c536df0d92da62d3185da6b65 Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:18:39 -0700 Subject: [PATCH 02/12] feat(telegram): render typed blocks as MarkdownV2 (#1982) Co-Authored-By: Claude Opus 4.8 --- .../sinks/telegram/telegram_transformer.py | 64 +++++++++++++++++++ tests/test_telegram_transformer.py | 39 +++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/robusta/core/sinks/telegram/telegram_transformer.py b/src/robusta/core/sinks/telegram/telegram_transformer.py index 1fde9dba9..25e70bc75 100644 --- a/src/robusta/core/sinks/telegram/telegram_transformer.py +++ b/src/robusta/core/sinks/telegram/telegram_transformer.py @@ -1,4 +1,18 @@ +import logging import re +from typing import List, Optional + +from robusta.core.reporting.base import BaseBlock +from robusta.core.reporting.blocks import ( + DividerBlock, + HeaderBlock, + JsonBlock, + KubernetesDiffBlock, + MarkdownBlock, +) + +MARKDOWNV2 = "MarkdownV2" +_DIVIDER = "-------------------" _MARKDOWNV2_SPECIAL_CHARS = r"_*[]()~`>#+-=|{}.!" _MARKDOWNV2_ESCAPE_RE = re.compile("([" + re.escape(_MARKDOWNV2_SPECIAL_CHARS) + "])") @@ -12,3 +26,53 @@ def escape_markdownv2(text: str) -> str: def escape_markdownv2_code(text: str) -> str: """Escape content placed inside a MarkdownV2 code/pre span (only ` and \\).""" return text.replace("\\", "\\\\").replace("`", "\\`") + + +class TelegramTransformer: + """Render Robusta blocks/values as Telegram MarkdownV2 (or plain text when parse_mode is None).""" + + def __init__(self, parse_mode: Optional[str] = MARKDOWNV2): + self.parse_mode = parse_mode + self.markdown = parse_mode == MARKDOWNV2 + + def escape(self, text: str) -> str: + return escape_markdownv2(text) if self.markdown else text + + def bold(self, text: str) -> str: + return f"*{escape_markdownv2(text)}*" if self.markdown else text + + def code(self, text: str) -> str: + return f"`{escape_markdownv2_code(text)}`" if self.markdown else text + + def link(self, text: str, url: str) -> str: + if self.markdown: + return f"[{escape_markdownv2(text)}]({url})" + return f"{text} ({url})" + + def block_to_markdownv2(self, block: BaseBlock) -> str: + if isinstance(block, MarkdownBlock): + return "" # handled in a later task + elif isinstance(block, HeaderBlock): + return self.bold(block.text) + elif isinstance(block, DividerBlock): + return self.escape(_DIVIDER) + elif isinstance(block, JsonBlock): + if self.markdown: + return f"```\n{escape_markdownv2_code(block.json_str)}\n```" + return block.json_str + elif isinstance(block, KubernetesDiffBlock): + lines = [] + for diff in block.diffs: + path = ".".join(diff.path) + lines.append( + f"{self.bold(path)}: {self.escape(str(diff.other_value))} " + f"{self.escape('==>')} {self.escape(str(diff.value))}" + ) + return "\n".join(lines) + else: + logging.debug(f"Unsupported block type ({type(block)}) for telegram MarkdownV2 rendering") + return "" + + def to_markdownv2(self, blocks: List[BaseBlock]) -> str: + rendered = [self.block_to_markdownv2(block) for block in blocks] + return "\n".join(line for line in rendered if line) diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py index 9fbac32e2..3aca6760b 100644 --- a/tests/test_telegram_transformer.py +++ b/tests/test_telegram_transformer.py @@ -1,4 +1,11 @@ +from robusta.core.reporting.blocks import ( + DividerBlock, + HeaderBlock, + JsonBlock, + MarkdownBlock, +) from robusta.core.sinks.telegram.telegram_transformer import ( + TelegramTransformer, escape_markdownv2, escape_markdownv2_code, ) @@ -21,3 +28,35 @@ def test_escape_markdownv2_plain_text_unchanged(): def test_escape_markdownv2_code_only_backtick_and_backslash(): assert escape_markdownv2_code(r"a`b\c_d*e") == r"a\`b\\c_d*e" + + +def test_header_block_bold_and_escaped(): + t = TelegramTransformer("MarkdownV2") + assert t.block_to_markdownv2(HeaderBlock("pod_x crashed!")) == r"*pod\_x crashed\!*" + + +def test_divider_block_is_safe_literal(): + t = TelegramTransformer("MarkdownV2") + out = t.block_to_markdownv2(DividerBlock()) + assert out # non-empty + # every dash must be backslash-escaped; the only chars in the output are "\" and "-" + assert set(out) == {"\\", "-"} + assert "-" not in out.replace("\\-", "") # no unescaped dash remains + + +def test_json_block_wrapped_in_code_fence(): + t = TelegramTransformer("MarkdownV2") + out = t.block_to_markdownv2(JsonBlock('{"a": 1}')) + assert out.startswith("```") and out.rstrip().endswith("```") + assert '{"a": 1}' in out # inner JSON has no ` or \, so it is unchanged + + +def test_to_markdownv2_joins_blocks(): + t = TelegramTransformer("MarkdownV2") + out = t.to_markdownv2([HeaderBlock("A"), DividerBlock(), HeaderBlock("B")]) + assert out.count("\n") == 2 + + +def test_plain_mode_header_no_markers(): + t = TelegramTransformer(None) + assert t.block_to_markdownv2(HeaderBlock("pod_x")) == "pod_x" From c6d083f0e64f8c934b66767bff39f5909a03d824 Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:20:20 -0700 Subject: [PATCH 03/12] feat(telegram): convert MarkdownBlock inline markup to MarkdownV2 (#1982) Co-Authored-By: Claude Opus 4.8 --- .../sinks/telegram/telegram_transformer.py | 35 ++++++++++++++++- tests/test_telegram_transformer.py | 38 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/robusta/core/sinks/telegram/telegram_transformer.py b/src/robusta/core/sinks/telegram/telegram_transformer.py index 25e70bc75..251f75de3 100644 --- a/src/robusta/core/sinks/telegram/telegram_transformer.py +++ b/src/robusta/core/sinks/telegram/telegram_transformer.py @@ -17,6 +17,14 @@ _MARKDOWNV2_SPECIAL_CHARS = r"_*[]()~`>#+-=|{}.!" _MARKDOWNV2_ESCAPE_RE = re.compile("([" + re.escape(_MARKDOWNV2_SPECIAL_CHARS) + "])") +# inline token: slack link | github link [text](url) | code `...` | bold *...* +_INLINE_RE = re.compile( + r"<(?P[^|>]+)\|(?P[^>]+)>" + r"|\[(?P[^\]]+)\]\((?P[^)]+)\)" + r"|`(?P[^`]+)`" + r"|\*(?P[^*]+)\*" +) + def escape_markdownv2(text: str) -> str: """Escape all Telegram MarkdownV2 special characters in arbitrary text.""" @@ -51,7 +59,7 @@ def link(self, text: str, url: str) -> str: def block_to_markdownv2(self, block: BaseBlock) -> str: if isinstance(block, MarkdownBlock): - return "" # handled in a later task + return self._inline_to_markdownv2(block.text) if block.text else "" elif isinstance(block, HeaderBlock): return self.bold(block.text) elif isinstance(block, DividerBlock): @@ -73,6 +81,31 @@ def block_to_markdownv2(self, block: BaseBlock) -> str: logging.debug(f"Unsupported block type ({type(block)}) for telegram MarkdownV2 rendering") return "" + def _inline_to_markdownv2(self, text: str) -> str: + if not self.markdown: + # plain text: strip the markers, keep readable link text + text = re.sub(r"<([^|>]+)\|([^>]+)>", r"\2 (\1)", text) + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text) + text = re.sub(r"`([^`]+)`", r"\1", text) + text = re.sub(r"\*([^*]+)\*", r"\1", text) + return text + + out = [] + last = 0 + for m in _INLINE_RE.finditer(text): + out.append(escape_markdownv2(text[last:m.start()])) # plain run before token + if m.group("slack_url") is not None: + out.append(self.link(m.group("slack_text"), m.group("slack_url"))) + elif m.group("gh_text") is not None: + out.append(self.link(m.group("gh_text"), m.group("gh_url"))) + elif m.group("code") is not None: + out.append(self.code(m.group("code"))) + elif m.group("bold") is not None: + out.append(self.bold(m.group("bold"))) + last = m.end() + out.append(escape_markdownv2(text[last:])) # trailing plain run + return "".join(out) + def to_markdownv2(self, blocks: List[BaseBlock]) -> str: rendered = [self.block_to_markdownv2(block) for block in blocks] return "\n".join(line for line in rendered if line) diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py index 3aca6760b..a97f1714c 100644 --- a/tests/test_telegram_transformer.py +++ b/tests/test_telegram_transformer.py @@ -60,3 +60,41 @@ def test_to_markdownv2_joins_blocks(): def test_plain_mode_header_no_markers(): t = TelegramTransformer(None) assert t.block_to_markdownv2(HeaderBlock("pod_x")) == "pod_x" + + +def test_markdown_block_preserves_bold_escapes_content(): + t = TelegramTransformer("MarkdownV2") + # underscore in surrounding text is escaped; *bold* preserved with escaped inner text + out = t.block_to_markdownv2(MarkdownBlock("pod_x is *down_now*")) + assert out == r"pod\_x is *down\_now*" + + +def test_markdown_block_preserves_code(): + t = TelegramTransformer("MarkdownV2") + out = t.block_to_markdownv2(MarkdownBlock("see `value_1` here")) + assert out == r"see `value_1` here" # inside code, _ is not escaped + + +def test_markdown_block_slack_link(): + t = TelegramTransformer("MarkdownV2") + out = t.block_to_markdownv2(MarkdownBlock("")) + assert out == r"[click\_here](https://x.io/a_b)" + + +def test_markdown_block_github_link(): + t = TelegramTransformer("MarkdownV2") + out = t.block_to_markdownv2(MarkdownBlock("[click_here](https://x.io/a_b)")) + assert out == r"[click\_here](https://x.io/a_b)" + + +def test_markdown_block_unbalanced_asterisk_does_not_crash(): + t = TelegramTransformer("MarkdownV2") + out = t.block_to_markdownv2(MarkdownBlock("weird * lonely _ marks")) + # lonely markers are escaped, never emitted raw + assert out == r"weird \* lonely \_ marks" + + +def test_markdown_block_plain_mode_strips_markers(): + t = TelegramTransformer(None) + out = t.block_to_markdownv2(MarkdownBlock("pod_x is *down* see ")) + assert out == "pod_x is down see here (https://x.io)" From 1c74cff554f4548f667c7c1d5416977bebc87d7a Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:21:19 -0700 Subject: [PATCH 04/12] feat(telegram): add configurable parse_mode sink param (#1982) Co-Authored-By: Claude Opus 4.8 --- .../sinks/telegram/telegram_sink_params.py | 12 +++++++++- tests/test_telegram_transformer.py | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/robusta/core/sinks/telegram/telegram_sink_params.py b/src/robusta/core/sinks/telegram/telegram_sink_params.py index cf1d002b0..936299992 100644 --- a/src/robusta/core/sinks/telegram/telegram_sink_params.py +++ b/src/robusta/core/sinks/telegram/telegram_sink_params.py @@ -1,4 +1,6 @@ -from typing import Union +from typing import Optional, Union + +from pydantic import validator from robusta.core.sinks.sink_base_params import SinkBaseParams from robusta.core.sinks.sink_config import SinkConfigBase @@ -9,11 +11,19 @@ class TelegramSinkParams(SinkBaseParams): chat_id: Union[int, str] thread_id: int = None send_files: bool = True # Change to False, to omit file attachments + parse_mode: Optional[str] = "MarkdownV2" # "MarkdownV2" or None (plain text) @classmethod def _get_sink_type(cls): return "telegram" + @validator("parse_mode") + def validate_parse_mode(cls, value): + allowed = {"MarkdownV2", None} + if value not in allowed: + raise ValueError(f"telegram parse_mode must be one of {allowed}, got {value!r}") + return value + class TelegramSinkConfigWrapper(SinkConfigBase): telegram_sink: TelegramSinkParams diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py index a97f1714c..e342da0c1 100644 --- a/tests/test_telegram_transformer.py +++ b/tests/test_telegram_transformer.py @@ -1,9 +1,13 @@ +import pytest +from pydantic import ValidationError + from robusta.core.reporting.blocks import ( DividerBlock, HeaderBlock, JsonBlock, MarkdownBlock, ) +from robusta.core.sinks.telegram.telegram_sink_params import TelegramSinkParams from robusta.core.sinks.telegram.telegram_transformer import ( TelegramTransformer, escape_markdownv2, @@ -11,6 +15,12 @@ ) +def _params(**kw): + base = dict(name="tg", bot_token="t", chat_id=123) + base.update(kw) + return TelegramSinkParams(**base) + + def test_escape_markdownv2_underscore_pod_name(): # the exact repro from issue #1982 assert escape_markdownv2("crowdsec-agent_k8vkt") == r"crowdsec\-agent\_k8vkt" @@ -98,3 +108,16 @@ def test_markdown_block_plain_mode_strips_markers(): t = TelegramTransformer(None) out = t.block_to_markdownv2(MarkdownBlock("pod_x is *down* see ")) assert out == "pod_x is down see here (https://x.io)" + + +def test_parse_mode_defaults_to_markdownv2(): + assert _params().parse_mode == "MarkdownV2" + + +def test_parse_mode_accepts_none(): + assert _params(parse_mode=None).parse_mode is None + + +def test_parse_mode_rejects_unsupported(): + with pytest.raises(ValidationError): + _params(parse_mode="HTML") From c1d1617115fc4c5b5883aaec4e270159b3286def Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:22:31 -0700 Subject: [PATCH 05/12] feat(telegram): thread parse_mode through client, omit when plain (#1982) Co-Authored-By: Claude Opus 4.8 --- .../core/sinks/telegram/telegram_client.py | 10 +++++--- tests/test_telegram_transformer.py | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/robusta/core/sinks/telegram/telegram_client.py b/src/robusta/core/sinks/telegram/telegram_client.py index fb23d34a4..eaa84b8c8 100644 --- a/src/robusta/core/sinks/telegram/telegram_client.py +++ b/src/robusta/core/sinks/telegram/telegram_client.py @@ -1,6 +1,6 @@ import logging import os -from typing import Union +from typing import Optional, Union import requests @@ -10,10 +10,13 @@ class TelegramClient: - def __init__(self, chat_id: Union[int, str], thread_id: int, bot_token: str): + def __init__( + self, chat_id: Union[int, str], thread_id: int, bot_token: str, parse_mode: Optional[str] = "MarkdownV2" + ): self.chat_id = int(chat_id) self.thread_id = thread_id self.bot_token = bot_token + self.parse_mode = parse_mode def send_message(self, message: str, disable_links_preview: bool = True): url = f"{TELEGRAM_BASE_URL}/bot{self.bot_token}/sendMessage" @@ -21,9 +24,10 @@ def send_message(self, message: str, disable_links_preview: bool = True): "chat_id": self.chat_id, "message_thread_id": self.thread_id, "disable_web_page_preview": disable_links_preview, - "parse_mode": "Markdown", "text": message, } + if self.parse_mode is not None: + message_json["parse_mode"] = self.parse_mode response = requests.post(url, json=message_json) if response.status_code != 200: diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py index e342da0c1..8266c8ccc 100644 --- a/tests/test_telegram_transformer.py +++ b/tests/test_telegram_transformer.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import pytest from pydantic import ValidationError @@ -121,3 +123,25 @@ def test_parse_mode_accepts_none(): def test_parse_mode_rejects_unsupported(): with pytest.raises(ValidationError): _params(parse_mode="HTML") + + +def test_client_sends_parse_mode_when_set(): + from robusta.core.sinks.telegram.telegram_client import TelegramClient + + client = TelegramClient(chat_id=1, thread_id=None, bot_token="x", parse_mode="MarkdownV2") + with patch("robusta.core.sinks.telegram.telegram_client.requests.post") as post: + post.return_value.status_code = 200 + client.send_message("hi") + body = post.call_args.kwargs["json"] + assert body["parse_mode"] == "MarkdownV2" + + +def test_client_omits_parse_mode_when_none(): + from robusta.core.sinks.telegram.telegram_client import TelegramClient + + client = TelegramClient(chat_id=1, thread_id=None, bot_token="x", parse_mode=None) + with patch("robusta.core.sinks.telegram.telegram_client.requests.post") as post: + post.return_value.status_code = 200 + client.send_message("hi") + body = post.call_args.kwargs["json"] + assert "parse_mode" not in body From 216774fe9863aa4e9f5441ad7d8a4839d0b5cd67 Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:24:43 -0700 Subject: [PATCH 06/12] fix(telegram): render messages via MarkdownV2 transformer (#1982) Co-Authored-By: Claude Opus 4.8 --- .../core/sinks/telegram/telegram_sink.py | 38 ++++++++++++------- tests/test_telegram_transformer.py | 25 ++++++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/robusta/core/sinks/telegram/telegram_sink.py b/src/robusta/core/sinks/telegram/telegram_sink.py index fd5a10c1e..67d8347e1 100644 --- a/src/robusta/core/sinks/telegram/telegram_sink.py +++ b/src/robusta/core/sinks/telegram/telegram_sink.py @@ -7,7 +7,7 @@ from robusta.core.sinks.sink_base import SinkBase from robusta.core.sinks.telegram.telegram_client import TelegramClient from robusta.core.sinks.telegram.telegram_sink_params import TelegramSinkConfigWrapper -from robusta.core.sinks.transformer import Transformer +from robusta.core.sinks.telegram.telegram_transformer import TelegramTransformer SEVERITY_EMOJI_MAP = { FindingSeverity.INFO: "\U0001F7E2", @@ -23,10 +23,10 @@ class TelegramSink(SinkBase): def __init__(self, sink_config: TelegramSinkConfigWrapper, registry): super().__init__(sink_config.telegram_sink, registry) - self.client = TelegramClient( - sink_config.telegram_sink.chat_id, sink_config.telegram_sink.thread_id, sink_config.telegram_sink.bot_token - ) - self.send_files = sink_config.telegram_sink.send_files + params = sink_config.telegram_sink + self.client = TelegramClient(params.chat_id, params.thread_id, params.bot_token, params.parse_mode) + self.send_files = params.send_files + self.transformer = TelegramTransformer(params.parse_mode) def write_finding(self, finding: Finding, platform_enabled: bool): self.__send_telegram_message(finding, platform_enabled) @@ -56,8 +56,7 @@ 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")] - + blocks = [] # first add finding description block if finding.description: blocks.append(MarkdownBlock(finding.description)) @@ -65,8 +64,13 @@ def __get_message_text(self, finding: Finding, platform_enabled: bool): for enrichment in finding.enrichments: blocks.extend([block for block in enrichment.blocks if self.__is_telegram_text_block(block)]) + source_line = f"{self.transformer.bold('Source:')} {self.transformer.code(self.cluster_name)}\n\n" + message_content += source_line + for block in blocks: - block_text = Transformer.to_standard_markdown([block]) + block_text = self.transformer.block_to_markdownv2(block) + if not block_text: + continue if len(block_text) + len(message_content) >= 4096: # telegram message size limit break message_content += block_text + "\n" @@ -77,13 +81,20 @@ def _get_actions_block(self, finding: Finding, platform_enabled: bool): actions_content = "" if platform_enabled: actions_content += ( - f"[{INVESTIGATE_ICON} Investigate]({finding.get_investigate_uri(self.account_id, self.cluster_name)}) " + self.transformer.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_content += self.transformer.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_content = self.transformer.link(link.link_text, link.url) if actions_content: actions_content += "\n\n" @@ -95,10 +106,9 @@ 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)) - @classmethod def __build_telegram_title( - cls, title: str, status: FindingStatus, severity: FindingSeverity, add_silence_url: bool + self, title: str, status: FindingStatus, severity: FindingSeverity, add_silence_url: bool ) -> 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} - {self.transformer.bold(title)}\n\n" diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py index 8266c8ccc..7d2f21d65 100644 --- a/tests/test_telegram_transformer.py +++ b/tests/test_telegram_transformer.py @@ -145,3 +145,28 @@ def test_client_omits_parse_mode_when_none(): client.send_message("hi") body = post.call_args.kwargs["json"] assert "parse_mode" not in body + + +def _render_sink_text(parse_mode="MarkdownV2"): + from robusta.core.reporting.base import Finding + from robusta.core.sinks.telegram.telegram_sink import TelegramSink + + with patch.object(TelegramSink, "__init__", lambda self, *a, **k: None): + sink = TelegramSink.__new__(TelegramSink) + sink.transformer = TelegramTransformer(parse_mode) + sink.cluster_name = "prod_cluster" + sink.account_id = "acc" + finding = Finding(title="Pod crowdsec-agent_k8vkt OOMKilled", aggregation_key="OOM") + return sink._TelegramSink__get_message_text(finding, platform_enabled=False) + + +def test_sink_escapes_underscore_in_title(): + text = _render_sink_text("MarkdownV2") + assert r"crowdsec\-agent\_k8vkt" in text + assert "crowdsec-agent_k8vkt" not in text # raw form must not leak through + + +def test_sink_plain_mode_has_no_escapes(): + text = _render_sink_text(None) + assert "crowdsec-agent_k8vkt" in text # raw, but plain text never crashes telegram + assert "\\" not in text From e41e0b8b3eff08dd2fe2e8a536ff00be4164426d Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:25:09 -0700 Subject: [PATCH 07/12] docs(telegram): document parse_mode option (#1982) Co-Authored-By: Claude Opus 4.8 --- docs/configuration/sinks/telegram.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/configuration/sinks/telegram.rst b/docs/configuration/sinks/telegram.rst index c25f54625..5d697ec97 100644 --- a/docs/configuration/sinks/telegram.rst +++ b/docs/configuration/sinks/telegram.rst @@ -52,10 +52,15 @@ Now we're ready to configure the Telegram sink. bot_token: chat_id: thread_id: # Optional thread (topic) ID + parse_mode: MarkdownV2 # Optional. "MarkdownV2" (default) or null for plain text .. note:: If you don't want Robusta to send file attachments, set ``send_files`` to ``False`` under your Telegram sink. (True by default) +.. note:: + + By default, Robusta formats Telegram messages using `MarkdownV2 `_, escaping special characters in your alert content so resource names containing characters like ``_`` (e.g. ``crowdsec-agent_k8vkt``) don't break message formatting. If you'd rather receive unformatted messages, set ``parse_mode`` to ``null`` to send plain text. + Save the file and run .. code-block:: bash From b8fc49fe10ed1c666c81847bc056fee62b26b00f Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:27:23 -0700 Subject: [PATCH 08/12] style(telegram): apply black/isort formatting (#1982) Co-Authored-By: Claude Opus 4.8 --- .../core/sinks/telegram/telegram_transformer.py | 10 ++-------- tests/test_telegram_transformer.py | 11 ++--------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/robusta/core/sinks/telegram/telegram_transformer.py b/src/robusta/core/sinks/telegram/telegram_transformer.py index 251f75de3..93583f196 100644 --- a/src/robusta/core/sinks/telegram/telegram_transformer.py +++ b/src/robusta/core/sinks/telegram/telegram_transformer.py @@ -3,13 +3,7 @@ from typing import List, Optional from robusta.core.reporting.base import BaseBlock -from robusta.core.reporting.blocks import ( - DividerBlock, - HeaderBlock, - JsonBlock, - KubernetesDiffBlock, - MarkdownBlock, -) +from robusta.core.reporting.blocks import DividerBlock, HeaderBlock, JsonBlock, KubernetesDiffBlock, MarkdownBlock MARKDOWNV2 = "MarkdownV2" _DIVIDER = "-------------------" @@ -93,7 +87,7 @@ def _inline_to_markdownv2(self, text: str) -> str: out = [] last = 0 for m in _INLINE_RE.finditer(text): - out.append(escape_markdownv2(text[last:m.start()])) # plain run before token + out.append(escape_markdownv2(text[last : m.start()])) # plain run before token if m.group("slack_url") is not None: out.append(self.link(m.group("slack_text"), m.group("slack_url"))) elif m.group("gh_text") is not None: diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py index 7d2f21d65..5c560ec01 100644 --- a/tests/test_telegram_transformer.py +++ b/tests/test_telegram_transformer.py @@ -3,12 +3,7 @@ import pytest from pydantic import ValidationError -from robusta.core.reporting.blocks import ( - DividerBlock, - HeaderBlock, - JsonBlock, - MarkdownBlock, -) +from robusta.core.reporting.blocks import DividerBlock, HeaderBlock, JsonBlock, MarkdownBlock from robusta.core.sinks.telegram.telegram_sink_params import TelegramSinkParams from robusta.core.sinks.telegram.telegram_transformer import ( TelegramTransformer, @@ -29,9 +24,7 @@ def test_escape_markdownv2_underscore_pod_name(): def test_escape_markdownv2_all_special_chars(): - assert escape_markdownv2("_*[]()~`>#+-=|{}.!") == ( - r"\_\*\[\]\(\)\~\`\>\#\+\-\=\|\{\}\.\!" - ) + assert escape_markdownv2("_*[]()~`>#+-=|{}.!") == (r"\_\*\[\]\(\)\~\`\>\#\+\-\=\|\{\}\.\!") def test_escape_markdownv2_plain_text_unchanged(): From b05b6f4142d57fed8d7ba92a78d2438dbbfd914f Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:29:20 -0700 Subject: [PATCH 09/12] fix(telegram): escape URLs in MarkdownV2 links, drop unused batch method (#1982) Final-review fixes: - escape ) and \ inside link URLs per MarkdownV2 spec - remove unused TelegramTransformer.to_markdownv2 batch method (YAGNI) Co-Authored-By: Claude Opus 4.8 --- .../core/sinks/telegram/telegram_transformer.py | 13 +++++++------ tests/test_telegram_transformer.py | 12 ++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/robusta/core/sinks/telegram/telegram_transformer.py b/src/robusta/core/sinks/telegram/telegram_transformer.py index 93583f196..e5f40058f 100644 --- a/src/robusta/core/sinks/telegram/telegram_transformer.py +++ b/src/robusta/core/sinks/telegram/telegram_transformer.py @@ -1,6 +1,6 @@ import logging import re -from typing import List, Optional +from typing import Optional from robusta.core.reporting.base import BaseBlock from robusta.core.reporting.blocks import DividerBlock, HeaderBlock, JsonBlock, KubernetesDiffBlock, MarkdownBlock @@ -30,6 +30,11 @@ def escape_markdownv2_code(text: str) -> str: return text.replace("\\", "\\\\").replace("`", "\\`") +def escape_markdownv2_url(url: str) -> str: + """Escape content placed inside the (...) of a MarkdownV2 inline link (only ) and \\).""" + return url.replace("\\", "\\\\").replace(")", "\\)") + + class TelegramTransformer: """Render Robusta blocks/values as Telegram MarkdownV2 (or plain text when parse_mode is None).""" @@ -48,7 +53,7 @@ def code(self, text: str) -> str: def link(self, text: str, url: str) -> str: if self.markdown: - return f"[{escape_markdownv2(text)}]({url})" + return f"[{escape_markdownv2(text)}]({escape_markdownv2_url(url)})" return f"{text} ({url})" def block_to_markdownv2(self, block: BaseBlock) -> str: @@ -99,7 +104,3 @@ def _inline_to_markdownv2(self, text: str) -> str: last = m.end() out.append(escape_markdownv2(text[last:])) # trailing plain run return "".join(out) - - def to_markdownv2(self, blocks: List[BaseBlock]) -> str: - rendered = [self.block_to_markdownv2(block) for block in blocks] - return "\n".join(line for line in rendered if line) diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py index 5c560ec01..dd6f1552b 100644 --- a/tests/test_telegram_transformer.py +++ b/tests/test_telegram_transformer.py @@ -56,12 +56,6 @@ def test_json_block_wrapped_in_code_fence(): assert '{"a": 1}' in out # inner JSON has no ` or \, so it is unchanged -def test_to_markdownv2_joins_blocks(): - t = TelegramTransformer("MarkdownV2") - out = t.to_markdownv2([HeaderBlock("A"), DividerBlock(), HeaderBlock("B")]) - assert out.count("\n") == 2 - - def test_plain_mode_header_no_markers(): t = TelegramTransformer(None) assert t.block_to_markdownv2(HeaderBlock("pod_x")) == "pod_x" @@ -92,6 +86,12 @@ def test_markdown_block_github_link(): assert out == r"[click\_here](https://x.io/a_b)" +def test_link_escapes_closing_paren_in_url(): + t = TelegramTransformer("MarkdownV2") + # a ) inside the URL must be escaped, or it would terminate the link early + assert t.link("docs", "https://x.io/foo(bar)") == r"[docs](https://x.io/foo(bar\))" + + def test_markdown_block_unbalanced_asterisk_does_not_crash(): t = TelegramTransformer("MarkdownV2") out = t.block_to_markdownv2(MarkdownBlock("weird * lonely _ marks")) From 021b18a080c2a322fdcc64e1d2b1d00e6363531a Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 11:34:57 -0700 Subject: [PATCH 10/12] docs(telegram): mark the MarkdownV2 converter extension point (#1982) Co-Authored-By: Claude Opus 4.8 --- src/robusta/core/sinks/telegram/telegram_transformer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/robusta/core/sinks/telegram/telegram_transformer.py b/src/robusta/core/sinks/telegram/telegram_transformer.py index e5f40058f..6c1626ee4 100644 --- a/src/robusta/core/sinks/telegram/telegram_transformer.py +++ b/src/robusta/core/sinks/telegram/telegram_transformer.py @@ -12,6 +12,10 @@ _MARKDOWNV2_ESCAPE_RE = re.compile("([" + re.escape(_MARKDOWNV2_SPECIAL_CHARS) + "])") # inline token: slack link | github link [text](url) | code `...` | bold *...* +# Extension point: to preserve more MarkdownV2 constructs in body text (italic _..._, +# strikethrough ~...~, spoiler ||...||), add an alternative group here and a matching +# branch in _inline_to_markdownv2. The escape char set itself is fixed by Telegram's +# MarkdownV2 spec and lives in _MARKDOWNV2_SPECIAL_CHARS. _INLINE_RE = re.compile( r"<(?P[^|>]+)\|(?P[^>]+)>" r"|\[(?P[^\]]+)\]\((?P[^)]+)\)" From 8ac36d0e3750baf11a811334b2237db8c9278eaa Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 12:49:16 -0700 Subject: [PATCH 11/12] fix(telegram): add request timeouts and accumulate action links (#1982) Address PR review: - pass a 30s timeout to both Telegram API calls so a slow/unreachable API can't hang the notification path - accumulate finding.links into actions_content instead of overwriting, so all links (and Investigate/Silence) survive - regression tests for link aggregation and the request timeout Co-Authored-By: Claude Opus 4.8 --- .../core/sinks/telegram/telegram_client.py | 6 +++-- .../core/sinks/telegram/telegram_sink.py | 2 +- tests/test_telegram_transformer.py | 24 ++++++++++++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/robusta/core/sinks/telegram/telegram_client.py b/src/robusta/core/sinks/telegram/telegram_client.py index eaa84b8c8..242964583 100644 --- a/src/robusta/core/sinks/telegram/telegram_client.py +++ b/src/robusta/core/sinks/telegram/telegram_client.py @@ -7,6 +7,8 @@ from robusta.core.reporting.utils import PNG_SUFFIX, SVG_SUFFIX, convert_svg_to_png, is_image TELEGRAM_BASE_URL = os.environ.get("TELEGRAM_BASE_URL", "https://api.telegram.org") +# guard the notification path against a slow/unreachable Telegram API hanging the sink +TELEGRAM_REQUEST_TIMEOUT_SECONDS = 30 class TelegramClient: @@ -28,7 +30,7 @@ def send_message(self, message: str, disable_links_preview: bool = True): } if self.parse_mode is not None: message_json["parse_mode"] = self.parse_mode - response = requests.post(url, json=message_json) + response = requests.post(url, json=message_json, timeout=TELEGRAM_REQUEST_TIMEOUT_SECONDS) if response.status_code != 200: logging.error( @@ -43,7 +45,7 @@ def send_file(self, file_name: str, contents: bytes): file_name = file_name.replace(SVG_SUFFIX, PNG_SUFFIX) files = {file_type.lower(): (file_name, contents)} - response = requests.post(url, files=files) + response = requests.post(url, files=files, timeout=TELEGRAM_REQUEST_TIMEOUT_SECONDS) if response.status_code != 200: logging.error( diff --git a/src/robusta/core/sinks/telegram/telegram_sink.py b/src/robusta/core/sinks/telegram/telegram_sink.py index 67d8347e1..e3c6ce64c 100644 --- a/src/robusta/core/sinks/telegram/telegram_sink.py +++ b/src/robusta/core/sinks/telegram/telegram_sink.py @@ -94,7 +94,7 @@ def _get_actions_block(self, finding: Finding, platform_enabled: bool): ) for link in finding.links: - actions_content = self.transformer.link(link.link_text, link.url) + actions_content += self.transformer.link(link.link_text, link.url) + " " if actions_content: actions_content += "\n\n" diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py index dd6f1552b..4a048517d 100644 --- a/tests/test_telegram_transformer.py +++ b/tests/test_telegram_transformer.py @@ -140,7 +140,7 @@ def test_client_omits_parse_mode_when_none(): assert "parse_mode" not in body -def _render_sink_text(parse_mode="MarkdownV2"): +def _render_sink_text(parse_mode="MarkdownV2", links=None): from robusta.core.reporting.base import Finding from robusta.core.sinks.telegram.telegram_sink import TelegramSink @@ -150,6 +150,8 @@ def _render_sink_text(parse_mode="MarkdownV2"): sink.cluster_name = "prod_cluster" sink.account_id = "acc" finding = Finding(title="Pod crowdsec-agent_k8vkt OOMKilled", aggregation_key="OOM") + if links: + finding.links = links return sink._TelegramSink__get_message_text(finding, platform_enabled=False) @@ -163,3 +165,23 @@ def test_sink_plain_mode_has_no_escapes(): text = _render_sink_text(None) assert "crowdsec-agent_k8vkt" in text # raw, but plain text never crashes telegram assert "\\" not in text + + +def test_sink_aggregates_multiple_action_links(): + from robusta.core.reporting.base import Link + + links = [Link(url="https://a.io/1", name="first"), Link(url="https://b.io/2", name="second")] + text = _render_sink_text("MarkdownV2", links=links) + # both links must survive; the old code overwrote and kept only the last one + assert "first" in text and "https://a.io/1" in text + assert "second" in text and "https://b.io/2" in text + + +def test_client_send_message_passes_timeout(): + from robusta.core.sinks.telegram.telegram_client import TelegramClient + + client = TelegramClient(chat_id=1, thread_id=None, bot_token="x") + with patch("robusta.core.sinks.telegram.telegram_client.requests.post") as post: + post.return_value.status_code = 200 + client.send_message("hi") + assert post.call_args.kwargs["timeout"] == 30 From 84ba4349b5064b9a9511d94b627513c19b70b45d Mon Sep 17 00:00:00 2001 From: Arun Selvamani Date: Sat, 20 Jun 2026 12:54:38 -0700 Subject: [PATCH 12/12] fix(telegram): preserve github-style **bold** in inline conversion (#1982) Address PR review: handle double-asterisk bold (and strip it in plain mode) so it renders as MarkdownV2 *bold* instead of leaving stray literal asterisks. Co-Authored-By: Claude Opus 4.8 --- .../core/sinks/telegram/telegram_transformer.py | 4 ++++ tests/test_telegram_transformer.py | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/robusta/core/sinks/telegram/telegram_transformer.py b/src/robusta/core/sinks/telegram/telegram_transformer.py index 6c1626ee4..434422452 100644 --- a/src/robusta/core/sinks/telegram/telegram_transformer.py +++ b/src/robusta/core/sinks/telegram/telegram_transformer.py @@ -20,6 +20,7 @@ r"<(?P[^|>]+)\|(?P[^>]+)>" r"|\[(?P[^\]]+)\]\((?P[^)]+)\)" r"|`(?P[^`]+)`" + r"|\*\*(?P[^*]+)\*\*" # github-style **bold** — must precede the single-* alternative r"|\*(?P[^*]+)\*" ) @@ -90,6 +91,7 @@ def _inline_to_markdownv2(self, text: str) -> str: text = re.sub(r"<([^|>]+)\|([^>]+)>", r"\2 (\1)", text) text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text) text = re.sub(r"`([^`]+)`", r"\1", text) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) text = re.sub(r"\*([^*]+)\*", r"\1", text) return text @@ -103,6 +105,8 @@ def _inline_to_markdownv2(self, text: str) -> str: out.append(self.link(m.group("gh_text"), m.group("gh_url"))) elif m.group("code") is not None: out.append(self.code(m.group("code"))) + elif m.group("bold2") is not None: + out.append(self.bold(m.group("bold2"))) elif m.group("bold") is not None: out.append(self.bold(m.group("bold"))) last = m.end() diff --git a/tests/test_telegram_transformer.py b/tests/test_telegram_transformer.py index 4a048517d..a9810f5e6 100644 --- a/tests/test_telegram_transformer.py +++ b/tests/test_telegram_transformer.py @@ -92,6 +92,17 @@ def test_link_escapes_closing_paren_in_url(): assert t.link("docs", "https://x.io/foo(bar)") == r"[docs](https://x.io/foo(bar\))" +def test_markdown_block_double_asterisk_bold(): + t = TelegramTransformer("MarkdownV2") + # github-style **bold** becomes MarkdownV2 *bold* with no stray asterisks + assert t.block_to_markdownv2(MarkdownBlock("see **down_now** ok")) == r"see *down\_now* ok" + + +def test_markdown_block_double_asterisk_plain_mode(): + t = TelegramTransformer(None) + assert t.block_to_markdownv2(MarkdownBlock("see **bold** ok")) == "see bold ok" + + def test_markdown_block_unbalanced_asterisk_does_not_crash(): t = TelegramTransformer("MarkdownV2") out = t.block_to_markdownv2(MarkdownBlock("weird * lonely _ marks"))