Skip to content

fix(telegram): escape MarkdownV2 to stop 'can't parse entities' (#1982)#2105

Open
arun2dot0 wants to merge 12 commits into
robusta-dev:masterfrom
arun2dot0:fix/telegram-markdownv2-escaping
Open

fix(telegram): escape MarkdownV2 to stop 'can't parse entities' (#1982)#2105
arun2dot0 wants to merge 12 commits into
robusta-dev:masterfrom
arun2dot0:fix/telegram-markdownv2-escaping

Conversation

@arun2dot0

Copy link
Copy Markdown

What

Fixes #1982 — the Telegram sink fails with Bad Request: can't parse entities when alert content contains Markdown special characters (e.g. Kubernetes names with underscores like crowdsec-agent_k8vkt).

Why the obvious fixes don't work

  • The sink deliberately builds Markdown — bold titles (*…*), code-formatted source (`…`), and clickable action links ([…](…)). So escaping the whole assembled message would break the sink's own formatting.
  • Telegram's legacy "Markdown" mode has no escape mechanism — you cannot backslash-escape a _. Escaping only works in MarkdownV2.
  • The message body is rendered from blocks as GitHub-flavored markup (**bold**, tables, dotted paths), which is not valid MarkdownV2 either — so the fix has to render the body specifically for Telegram.

What this PR does

  • Adds TelegramTransformer (telegram_transformer.py) which owns MarkdownV2 escaping and block→text rendering. Structural formatting is built by the sink with dynamic values escaped, so intentional bold/code/links are preserved while pod names, titles, descriptions, etc. are made safe.
  • Adds a configurable parse_mode sink param: "MarkdownV2" (default) or null for guaranteed-safe plain text. Validated to those two values; threaded through TelegramClient (the parse_mode key is omitted entirely when plain).
  • Body MarkdownBlock text goes through a small inline converter that preserves the constructs Robusta actually emits (*bold*, `code`, <url|text> and [text](url) links) and escapes everything else. Worst case for exotic markup is a literal * rendering rather than a parse crash.
  • URLs inside links escape ) and \ per the MarkdownV2 spec.
  • Documents parse_mode in docs/configuration/sinks/telegram.rst.

Testing

New pure-Python suite tests/test_telegram_transformer.py (22 tests), covering the #1982 repro (crowdsec-agent_k8vkt), every special char, the inline converter (bold/code/both link forms, adversarial unbalanced markers), URL escaping, the parse_mode param/validator, client request shaping, and an end-to-end sink render in both modes. tests/test_app_imports.py confirms no import regressions. Black/isort clean (line-length 120).

23 passed

Backwards compatibility

Default behavior changes from legacy Markdown to MarkdownV2, which is what makes messages robust by default. Users who want unformatted messages can set parse_mode: null.

Follow-up recommendations (not in this PR)

  1. Richer body formatting — extend the inline converter to preserve more MarkdownV2 constructs in MarkdownBlock text (italic _…_, strikethrough ~…~, spoiler ||…||, blockquote). The converter has an explicit extension point comment for this.
  2. A shared sink UI-transformer baseTelegramTransformer follows a pattern that generalizes well: a per-platform transformer that (a) exposes structural builders (bold/code/link), (b) centralizes escaping for the target markup, and (c) renders typed blocks. Today each markup-based sink (Slack, MS Teams, Discord, Mattermost, …) handles these concerns ad hoc via the shared Transformer. Extracting a small base interface (e.g. escape, bold, code, link, block_to_text) that each sink implements would give consistent, individually-testable, escaping-correct rendering across platforms and reduce per-sink markup bugs like this one. Worth its own design issue.

arun2dot0 and others added 10 commits June 20, 2026 11:35
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…v#1982)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hod (robusta-dev#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 <noreply@anthropic.com>
…a-dev#1982)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Jun 20, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 953c47e1-8824-44fd-ad45-0bf312a60425

📥 Commits

Reviewing files that changed from the base of the PR and between 8ac36d0 and 84ba434.

📒 Files selected for processing (2)
  • src/robusta/core/sinks/telegram/telegram_transformer.py
  • tests/test_telegram_transformer.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/robusta/core/sinks/telegram/telegram_transformer.py
  • tests/test_telegram_transformer.py

Walkthrough

The PR fixes Telegram "can't parse entities" errors caused by unescaped Markdown in Kubernetes resource names. It adds a configurable parse_mode field to TelegramSinkParams and TelegramClient, introduces a new TelegramTransformer module for proper MarkdownV2 escaping and block rendering, rewires TelegramSink to use the transformer, and adds comprehensive tests and documentation.

Changes

Telegram MarkdownV2 Escaping and Configurable parse_mode

Layer / File(s) Summary
parse_mode config schema and client wiring
src/robusta/core/sinks/telegram/telegram_sink_params.py, src/robusta/core/sinks/telegram/telegram_client.py
TelegramSinkParams adds a parse_mode field defaulting to "MarkdownV2" with a Pydantic validator restricting values to "MarkdownV2" or None. TelegramClient.__init__ accepts parse_mode and send_message conditionally includes it in the Telegram API request body instead of hardcoding "Markdown". Request timeouts (30 seconds) are introduced for both send_message and send_file.
TelegramTransformer: MarkdownV2 escaping and block rendering
src/robusta/core/sinks/telegram/telegram_transformer.py
New module defining escape_markdownv2, escape_markdownv2_code, and escape_markdownv2_url helpers, plus TelegramTransformer class which renders MarkdownBlock, HeaderBlock, DividerBlock, JsonBlock, and KubernetesDiffBlock into either MarkdownV2 format (with inline token parsing for Slack/GitHub links, code, and bold) or plain text based on parse_mode.
TelegramSink rewired to use TelegramTransformer
src/robusta/core/sinks/telegram/telegram_sink.py
TelegramSink.__init__ caches sink_config.telegram_sink as params, passes params.parse_mode to TelegramClient, and instantiates self.transformer. Block rendering in __get_message_text switches to self.transformer.block_to_markdownv2. Action links in _get_actions_block use self.transformer.link with emoji-text labels, and now accumulate multiple links instead of overwriting. Method __build_telegram_title becomes an instance method using self.transformer.bold.
Tests and documentation
tests/test_telegram_transformer.py, docs/configuration/sinks/telegram.rst
Comprehensive pytest module covers escape function behavior, block rendering for all supported types, parse_mode validation, TelegramClient payload formation with and without parse_mode, request timeout assertion, and sink text output in both MarkdownV2 and plain modes. Documentation adds parse_mode field to the config example and explains MarkdownV2 escaping behavior and plain-text opt-out via parse_mode: null.

Sequence Diagram

sequenceDiagram
  participant Sink as TelegramSink
  participant Transformer as TelegramTransformer
  participant Client as TelegramClient
  participant API as Telegram API
  Sink->>Sink: __init__(sink_config)
  Sink->>Transformer: TelegramTransformer(params.parse_mode)
  Sink->>Client: TelegramClient(..., parse_mode=params.parse_mode)
  Sink->>Sink: __get_message_text(blocks)
  Sink->>Transformer: block_to_markdownv2(block)
  Transformer->>Transformer: escape_markdownv2 or plain text format
  Transformer-->>Sink: formatted block text
  Sink->>Client: send_message(formatted_text)
  Client->>API: POST with parse_mode in payload (or omit if None)
  API-->>Client: 200 OK
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.12% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing Telegram MarkdownV2 escaping to resolve the 'can't parse entities' error mentioned in issue #1982.
Description check ✅ Passed The description clearly explains the bug, why obvious fixes don't work, and what the PR does, including the new TelegramTransformer, parse_mode parameter, and testing approach.
Linked Issues check ✅ Passed The PR fully addresses all objectives from issue #1982 [#1982]: adds configurable parse_mode parameter, implements proper MarkdownV2 escaping via TelegramTransformer, provides fallback plain-text mode, and includes comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly related to resolving issue #1982: TelegramTransformer implementation, parse_mode parameter addition, documentation updates, and comprehensive testing are all within scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
tests/test_telegram_transformer.py (1)

143-166: ⚡ Quick win

Add a regression test for action-link aggregation in sink text generation.

Given the sink wiring changes, add a case with multiple finding.links (and optionally platform_enabled=True) to ensure actions are appended, not replaced.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_telegram_transformer.py` around lines 143 - 166, Add a new test
function after the existing tests in the file that creates a Finding object with
multiple links (in the `finding.links` attribute) to verify that action links
are properly aggregated and appended in the sink text output rather than being
replaced. The test should call the _render_sink_text helper function
(potentially with an updated version that accepts links as a parameter) and
assert that all links appear in the generated message text. Additionally,
consider testing with platform_enabled=True to ensure the behavior is consistent
across different platform enablement states.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/robusta/core/sinks/telegram/telegram_client.py`:
- Around line 21-31: Add a timeout parameter to the requests.post call in the
send_message method to prevent indefinite hangs when the Telegram API is slow or
unreachable. Also apply the same fix to the send_file method mentioned in the
comment. Include a reasonable timeout value (e.g., 10-30 seconds) as a parameter
in all external network calls to ensure the notification sink remains responsive
and available.

In `@src/robusta/core/sinks/telegram/telegram_sink.py`:
- Around line 96-97: The actions_content variable is being overwritten on each
iteration of the finding.links loop instead of accumulating the results. In the
loop where you iterate through finding.links and call self.transformer.link,
change the assignment operation to accumulate the link content rather than
replace it (use an accumulation pattern like += instead of = assignment) so that
all links are preserved in the final actions_content variable instead of only
the last link being retained.

In `@src/robusta/core/sinks/telegram/telegram_transformer.py`:
- Around line 19-24: The bold pattern in the _INLINE_RE regex currently only
matches single asterisks (*bold*) but not GitHub-style double asterisks
(**bold**). Update the bold regex pattern to handle both single and double
asterisks for bold text formatting. Additionally, review the code that processes
these regex groups (around lines 87-110) to ensure it correctly handles both
bold pattern variations when converting them to Telegram formatting.

---

Nitpick comments:
In `@tests/test_telegram_transformer.py`:
- Around line 143-166: Add a new test function after the existing tests in the
file that creates a Finding object with multiple links (in the `finding.links`
attribute) to verify that action links are properly aggregated and appended in
the sink text output rather than being replaced. The test should call the
_render_sink_text helper function (potentially with an updated version that
accepts links as a parameter) and assert that all links appear in the generated
message text. Additionally, consider testing with platform_enabled=True to
ensure the behavior is consistent across different platform enablement states.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 53a999d4-0433-45d6-9708-b76b080c0cb0

📥 Commits

Reviewing files that changed from the base of the PR and between 7f40b28 and 72ec427.

📒 Files selected for processing (6)
  • docs/configuration/sinks/telegram.rst
  • src/robusta/core/sinks/telegram/telegram_client.py
  • src/robusta/core/sinks/telegram/telegram_sink.py
  • src/robusta/core/sinks/telegram/telegram_sink_params.py
  • src/robusta/core/sinks/telegram/telegram_transformer.py
  • tests/test_telegram_transformer.py

Comment thread src/robusta/core/sinks/telegram/telegram_client.py Outdated
Comment thread src/robusta/core/sinks/telegram/telegram_sink.py Outdated
Comment thread src/robusta/core/sinks/telegram/telegram_transformer.py
@arun2dot0 arun2dot0 force-pushed the fix/telegram-markdownv2-escaping branch from 72ec427 to 021b18a Compare June 20, 2026 18:59
…sta-dev#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 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_telegram_transformer.py (1)

180-187: ⚡ Quick win

Import and assert against the timeout constant instead of hardcoding the expected value.

The test hardcodes timeout == 30, but the actual code uses the TELEGRAM_REQUEST_TIMEOUT_SECONDS constant. This creates unnecessary coupling; if the constant changes, the test will fail even though the behavior is correct.

♻️ Proposed refactor to reference the constant
 def test_client_send_message_passes_timeout():
-    from robusta.core.sinks.telegram.telegram_client import TelegramClient
+    from robusta.core.sinks.telegram.telegram_client import TelegramClient, TELEGRAM_REQUEST_TIMEOUT_SECONDS
 
     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
+    assert post.call_args.kwargs["timeout"] == TELEGRAM_REQUEST_TIMEOUT_SECONDS
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_telegram_transformer.py` around lines 180 - 187, The
test_client_send_message_passes_timeout function hardcodes the expected timeout
value as 30 instead of referencing the actual constant used in the
implementation. Import the TELEGRAM_REQUEST_TIMEOUT_SECONDS constant from the
robusta.core.sinks.telegram.telegram_client module and replace the hardcoded
value 30 in the assertion with this constant so that the test will automatically
stay in sync if the timeout value changes in the future.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_telegram_transformer.py`:
- Around line 180-187: The test_client_send_message_passes_timeout function
hardcodes the expected timeout value as 30 instead of referencing the actual
constant used in the implementation. Import the TELEGRAM_REQUEST_TIMEOUT_SECONDS
constant from the robusta.core.sinks.telegram.telegram_client module and replace
the hardcoded value 30 in the assertion with this constant so that the test will
automatically stay in sync if the timeout value changes in the future.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1d4cea64-9ee9-45f9-bc75-cb394529f60e

📥 Commits

Reviewing files that changed from the base of the PR and between 021b18a and 8ac36d0.

📒 Files selected for processing (3)
  • src/robusta/core/sinks/telegram/telegram_client.py
  • src/robusta/core/sinks/telegram/telegram_sink.py
  • tests/test_telegram_transformer.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/robusta/core/sinks/telegram/telegram_sink.py

…obusta-dev#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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Telegram sink fails with "can't parse entities" due to unescaped Markdown

2 participants