From 5dc74fbe50f8bd0ebade75a862284e74e0391622 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 22 Aug 2026 21:38:32 +0200 Subject: [PATCH 1/9] feat: compact Telegram PR messages --- .../prepare-telegram-pr-message/prepare.py | 30 +++++++++------ .../actions/send-telegram-message/action.yml | 10 ++++- .../workflows/notify-telegram-pr-shared.yml | 1 + test/test_telegram_notifications.py | 37 +++++++++++++------ 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/.github/actions/prepare-telegram-pr-message/prepare.py b/.github/actions/prepare-telegram-pr-message/prepare.py index 02715d7..4fe2ce5 100644 --- a/.github/actions/prepare-telegram-pr-message/prepare.py +++ b/.github/actions/prepare-telegram-pr-message/prepare.py @@ -4,6 +4,7 @@ import subprocess import uuid from datetime import datetime, timezone +from html import escape DIGEST_PR_LIMIT = 10 TELEGRAM_MESSAGE_LIMIT = 4096 @@ -18,6 +19,10 @@ def author_login(pull_request): return (pull_request.get("author") or {}).get("login") or "ghost" +def pull_request_link(pull_request): + return f'#{pull_request["number"]}' + + def format_opened(repository, pull_request, event_action): if pull_request.get("isDraft"): return "" @@ -28,17 +33,15 @@ def format_opened(repository, pull_request, event_action): } emoji, event = events.get(event_action, ("🆕", "PR opened")) return ( - f"{emoji} {repository} — {event}\n" - f"#{pull_request['number']} {pull_request['title']}\n" - f"{author_login(pull_request)} · {pull_request['url']}" + f"{emoji} {escape(repository)} — {event} · {pull_request_link(pull_request)} " + f"{escape(pull_request['title'])} · {escape(author_login(pull_request))}" ) def format_merged(repository, pull_request): return ( - f"🔀 {repository} — PR merged\n" - f"#{pull_request['number']} {pull_request['title']}\n" - f"{author_login(pull_request)} · {pull_request['url']}" + f"🔀 {escape(repository)} — PR merged · {pull_request_link(pull_request)} " + f"{escape(pull_request['title'])} · {escape(author_login(pull_request))}" ) @@ -52,16 +55,21 @@ def format_digest(repository, pull_requests, now=None): return "" now = now or datetime.now(timezone.utc) - header = f"🔠 {repository} — {len(pull_requests)} open PR(s)" + count = len(pull_requests) + header = f"🔠 {escape(repository)} — {count} open {'PR' if count == 1 else 'PRs'}" lines = [] for pull_request in pull_requests[:DIGEST_PR_LIMIT]: age = max(0, (now - parse_github_time(pull_request["createdAt"])).days) line = ( - f"#{pull_request['number']} {pull_request['title']}\n" - f"{author_login(pull_request)} · {age}d · {pull_request['url']}" + f"{pull_request_link(pull_request)} {escape(pull_request['title'])} · " + f"{escape(author_login(pull_request))} · {age}d" ) remaining = len(pull_requests) - len(lines) - 1 - suffix = f"...and {remaining} more · https://github.com/{repository}/pulls" if remaining else "" + suffix = ( + f'...and {remaining} more' + if remaining + else "" + ) candidate = "\n".join((header, *lines, line, suffix)).rstrip() if len(candidate) > TELEGRAM_MESSAGE_LIMIT: break @@ -69,7 +77,7 @@ def format_digest(repository, pull_requests, now=None): remaining = len(pull_requests) - len(lines) if remaining: - lines.append(f"...and {remaining} more · https://github.com/{repository}/pulls") + lines.append(f'...and {remaining} more') return "\n".join((header, *lines)) diff --git a/.github/actions/send-telegram-message/action.yml b/.github/actions/send-telegram-message/action.yml index 55910ce..9f2b81c 100644 --- a/.github/actions/send-telegram-message/action.yml +++ b/.github/actions/send-telegram-message/action.yml @@ -1,5 +1,5 @@ name: Send Telegram message -description: Send a plain-text message to a Telegram chat. +description: Send a message to a Telegram chat. inputs: message: description: Message text. @@ -10,6 +10,9 @@ inputs: telegram-chat-id: description: Telegram chat ID. required: true + parse-mode: + description: Optional Telegram parse mode. + default: "" runs: using: composite steps: @@ -18,6 +21,7 @@ runs: MESSAGE: ${{ inputs.message }} BOT_TOKEN: ${{ inputs.telegram-bot-token }} CHAT_ID: ${{ inputs.telegram-chat-id }} + PARSE_MODE: ${{ inputs.parse-mode }} run: | set -euo pipefail curl -fsSL \ @@ -28,4 +32,6 @@ runs: -d "$(jq -n \ --arg c "$CHAT_ID" \ --arg t "$MESSAGE" \ - '{chat_id: $c, text: $t, link_preview_options: {is_disabled: true}}')" + --arg p "$PARSE_MODE" \ + '{chat_id: $c, text: $t, link_preview_options: {is_disabled: true}} + + if $p == "" then {} else {parse_mode: $p} end')" diff --git a/.github/workflows/notify-telegram-pr-shared.yml b/.github/workflows/notify-telegram-pr-shared.yml index 1fa9062..572f1bf 100644 --- a/.github/workflows/notify-telegram-pr-shared.yml +++ b/.github/workflows/notify-telegram-pr-shared.yml @@ -27,3 +27,4 @@ jobs: message: ${{ steps.prepare.outputs.message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }} + parse-mode: HTML diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index b504cef..7a85129 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -38,9 +38,8 @@ def test_formats_opened_pull_request(self): self.assertEqual( message, - "🆕 owner/repo — PR opened\n" - "#42 feat: add notifications\n" - "octocat · https://github.com/owner/repo/pull/42", + '🆕 owner/repo — PR opened · #42 ' + "feat: add notifications · octocat", ) def test_formats_ready_and_reopened_pull_request_actions(self): @@ -55,8 +54,20 @@ def test_skips_draft_pull_request(self): def test_formats_merged_pull_request(self): message = PR_TELEGRAM.format_merged("owner/repo", self.pull_request) - self.assertIn("🔀 owner/repo — PR merged", message) - self.assertIn("#42 feat: add notifications", message) + self.assertEqual( + message, + '🔀 owner/repo — PR merged · #42 ' + "feat: add notifications · octocat", + ) + + def test_escapes_pull_request_values_for_telegram_html(self): + self.pull_request["title"] = "fix: escape & text" + self.pull_request["author"] = {"login": "bot&name"} + + message = PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "opened") + + self.assertIn("fix: escape <markup> & text", message) + self.assertIn("bot&name", message) def test_open_pull_request_digest_excludes_drafts_and_reports_age(self): draft = dict(self.pull_request, number=41, isDraft=True) @@ -66,7 +77,7 @@ def test_open_pull_request_digest_excludes_drafts_and_reports_age(self): now=datetime(2026, 8, 12, 12, tzinfo=timezone.utc), ) - self.assertIn("1 open PR(s)", message) + self.assertIn("1 open PR", message) self.assertTrue(message.startswith("🔠 owner/repo")) self.assertIn("octocat · 2d", message) self.assertNotIn("#41", message) @@ -80,6 +91,7 @@ def test_open_pull_request_digest_lists_at_most_ten_pull_requests(self): self.pull_request | { "number": number, + "url": f"https://github.com/owner/repo/pull/{number}", "createdAt": "2026-08-10T12:00:00Z", } for number in range(1, 13) @@ -87,10 +99,10 @@ def test_open_pull_request_digest_lists_at_most_ten_pull_requests(self): message = PR_TELEGRAM.format_digest("owner/repo", pull_requests) - self.assertIn("12 open PR(s)", message) - self.assertIn("#10 feat: add notifications", message) - self.assertNotIn("#11 feat: add notifications", message) - self.assertIn("...and 2 more · https://github.com/owner/repo/pulls", message) + self.assertIn("12 open PRs", message) + self.assertIn('pull/10">#10 feat: add notifications', message) + self.assertNotIn('pull/11">#11 feat: add notifications', message) + self.assertIn('href="https://github.com/owner/repo/pulls">...and 2 more', message) def test_open_pull_request_digest_stays_within_telegram_limit(self): pull_requests = [ @@ -106,7 +118,7 @@ def test_open_pull_request_digest_stays_within_telegram_limit(self): message = PR_TELEGRAM.format_digest("owner/repo", pull_requests) self.assertLessEqual(len(message), PR_TELEGRAM.TELEGRAM_MESSAGE_LIMIT) - self.assertIn("more · https://github.com/owner/repo/pulls", message) + self.assertIn("more", message) def test_writes_multiline_github_output(self): with tempfile.NamedTemporaryFile() as output, patch.dict(os.environ, {"GITHUB_OUTPUT": output.name}): @@ -166,6 +178,7 @@ def test_pr_workflow_prepares_and_sends_supported_events(self): "$/.github/actions/send-telegram-message", ], ) + self.assertEqual(steps[-1]["with"]["parse-mode"], "HTML") def test_issue_workflow_only_sends_closed_issue_notification(self): workflow = self.load_workflow("notify-telegram-issue-shared.yml") @@ -207,6 +220,8 @@ def test_internal_actions_separate_formatting_and_delivery(self): self.assertNotIn("api.telegram.org", pr_action) self.assertNotIn("api.telegram.org", issue_action) self.assertIn("inputs:\n message:", send_action) + self.assertIn("parse-mode:", send_action) + self.assertIn("parse_mode", send_action) self.assertIn("--output /dev/null", send_action) self.assertIn("api.telegram.org", send_action) From 8553c4635611290ba705310c90336a427b89637d Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 22 Aug 2026 21:46:09 +0200 Subject: [PATCH 2/9] feat: emphasize Telegram PR titles --- .github/actions/prepare-telegram-pr-message/prepare.py | 6 +++--- test/test_telegram_notifications.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/actions/prepare-telegram-pr-message/prepare.py b/.github/actions/prepare-telegram-pr-message/prepare.py index 4fe2ce5..11630c5 100644 --- a/.github/actions/prepare-telegram-pr-message/prepare.py +++ b/.github/actions/prepare-telegram-pr-message/prepare.py @@ -34,14 +34,14 @@ def format_opened(repository, pull_request, event_action): emoji, event = events.get(event_action, ("🆕", "PR opened")) return ( f"{emoji} {escape(repository)} — {event} · {pull_request_link(pull_request)} " - f"{escape(pull_request['title'])} · {escape(author_login(pull_request))}" + f"{escape(pull_request['title'])} · {escape(author_login(pull_request))}" ) def format_merged(repository, pull_request): return ( f"🔀 {escape(repository)} — PR merged · {pull_request_link(pull_request)} " - f"{escape(pull_request['title'])} · {escape(author_login(pull_request))}" + f"{escape(pull_request['title'])} · {escape(author_login(pull_request))}" ) @@ -61,7 +61,7 @@ def format_digest(repository, pull_requests, now=None): for pull_request in pull_requests[:DIGEST_PR_LIMIT]: age = max(0, (now - parse_github_time(pull_request["createdAt"])).days) line = ( - f"{pull_request_link(pull_request)} {escape(pull_request['title'])} · " + f"{pull_request_link(pull_request)} {escape(pull_request['title'])} · " f"{escape(author_login(pull_request))} · {age}d" ) remaining = len(pull_requests) - len(lines) - 1 diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index 7a85129..53fb740 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -39,7 +39,7 @@ def test_formats_opened_pull_request(self): self.assertEqual( message, '🆕 owner/repo — PR opened · #42 ' - "feat: add notifications · octocat", + "feat: add notifications · octocat", ) def test_formats_ready_and_reopened_pull_request_actions(self): @@ -57,7 +57,7 @@ def test_formats_merged_pull_request(self): self.assertEqual( message, '🔀 owner/repo — PR merged · #42 ' - "feat: add notifications · octocat", + "feat: add notifications · octocat", ) def test_escapes_pull_request_values_for_telegram_html(self): @@ -66,7 +66,7 @@ def test_escapes_pull_request_values_for_telegram_html(self): message = PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "opened") - self.assertIn("fix: escape <markup> & text", message) + self.assertIn("fix: escape <markup> & text", message) self.assertIn("bot&name", message) def test_open_pull_request_digest_excludes_drafts_and_reports_age(self): @@ -100,8 +100,8 @@ def test_open_pull_request_digest_lists_at_most_ten_pull_requests(self): message = PR_TELEGRAM.format_digest("owner/repo", pull_requests) self.assertIn("12 open PRs", message) - self.assertIn('pull/10">#10 feat: add notifications', message) - self.assertNotIn('pull/11">#11 feat: add notifications', message) + self.assertIn('pull/10">#10 feat: add notifications', message) + self.assertNotIn('pull/11">#11 feat: add notifications', message) self.assertIn('href="https://github.com/owner/repo/pulls">...and 2 more', message) def test_open_pull_request_digest_stays_within_telegram_limit(self): From 6871708fe9413d9c081d48a22ac2269e7c43140f Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 22 Aug 2026 21:50:48 +0200 Subject: [PATCH 3/9] refactor: use Markdown for Telegram PR messages --- .../prepare-telegram-pr-message/prepare.py | 32 ++++++++++++------- .../workflows/notify-telegram-pr-shared.yml | 2 +- test/test_telegram_notifications.py | 28 ++++++++-------- 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/.github/actions/prepare-telegram-pr-message/prepare.py b/.github/actions/prepare-telegram-pr-message/prepare.py index 11630c5..7248ad1 100644 --- a/.github/actions/prepare-telegram-pr-message/prepare.py +++ b/.github/actions/prepare-telegram-pr-message/prepare.py @@ -4,9 +4,9 @@ import subprocess import uuid from datetime import datetime, timezone -from html import escape DIGEST_PR_LIMIT = 10 +MARKDOWN_V2_SPECIAL_CHARACTERS = frozenset("_*[]()~`>#+-=|{}.!\\") TELEGRAM_MESSAGE_LIMIT = 4096 @@ -19,8 +19,18 @@ def author_login(pull_request): return (pull_request.get("author") or {}).get("login") or "ghost" +def escape_markdown(value): + return "".join(f"\\{character}" if character in MARKDOWN_V2_SPECIAL_CHARACTERS else character for character in str(value)) + + +def escape_link_url(value): + return str(value).replace("\\", "\\\\").replace(")", "\\)") + + def pull_request_link(pull_request): - return f'#{pull_request["number"]}' + number = escape_markdown(f"#{pull_request['number']}") + url = escape_link_url(pull_request["url"]) + return f"[{number}]({url})" def format_opened(repository, pull_request, event_action): @@ -33,15 +43,15 @@ def format_opened(repository, pull_request, event_action): } emoji, event = events.get(event_action, ("🆕", "PR opened")) return ( - f"{emoji} {escape(repository)} — {event} · {pull_request_link(pull_request)} " - f"{escape(pull_request['title'])} · {escape(author_login(pull_request))}" + f"{emoji} {escape_markdown(repository)} — {event} · {pull_request_link(pull_request)} " + f"*{escape_markdown(pull_request['title'])}* · {escape_markdown(author_login(pull_request))}" ) def format_merged(repository, pull_request): return ( - f"🔀 {escape(repository)} — PR merged · {pull_request_link(pull_request)} " - f"{escape(pull_request['title'])} · {escape(author_login(pull_request))}" + f"🔀 {escape_markdown(repository)} — PR merged · {pull_request_link(pull_request)} " + f"*{escape_markdown(pull_request['title'])}* · {escape_markdown(author_login(pull_request))}" ) @@ -56,17 +66,17 @@ def format_digest(repository, pull_requests, now=None): now = now or datetime.now(timezone.utc) count = len(pull_requests) - header = f"🔠 {escape(repository)} — {count} open {'PR' if count == 1 else 'PRs'}" + header = f"🔠 {escape_markdown(repository)} — {count} open {'PR' if count == 1 else 'PRs'}" lines = [] for pull_request in pull_requests[:DIGEST_PR_LIMIT]: age = max(0, (now - parse_github_time(pull_request["createdAt"])).days) line = ( - f"{pull_request_link(pull_request)} {escape(pull_request['title'])} · " - f"{escape(author_login(pull_request))} · {age}d" + f"{pull_request_link(pull_request)} *{escape_markdown(pull_request['title'])}* · " + f"{escape_markdown(author_login(pull_request))} · {age}d" ) remaining = len(pull_requests) - len(lines) - 1 suffix = ( - f'...and {remaining} more' + f"[{escape_markdown(f'...and {remaining} more')}](https://github.com/{escape_link_url(repository)}/pulls)" if remaining else "" ) @@ -77,7 +87,7 @@ def format_digest(repository, pull_requests, now=None): remaining = len(pull_requests) - len(lines) if remaining: - lines.append(f'...and {remaining} more') + lines.append(f"[{escape_markdown(f'...and {remaining} more')}](https://github.com/{escape_link_url(repository)}/pulls)") return "\n".join((header, *lines)) diff --git a/.github/workflows/notify-telegram-pr-shared.yml b/.github/workflows/notify-telegram-pr-shared.yml index 572f1bf..749873c 100644 --- a/.github/workflows/notify-telegram-pr-shared.yml +++ b/.github/workflows/notify-telegram-pr-shared.yml @@ -27,4 +27,4 @@ jobs: message: ${{ steps.prepare.outputs.message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }} - parse-mode: HTML + parse-mode: MarkdownV2 diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index 53fb740..feaa72e 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -38,8 +38,8 @@ def test_formats_opened_pull_request(self): self.assertEqual( message, - '🆕 owner/repo — PR opened · #42 ' - "feat: add notifications · octocat", + "🆕 owner/repo — PR opened · [\\#42](https://github.com/owner/repo/pull/42) " + "*feat: add notifications* · octocat", ) def test_formats_ready_and_reopened_pull_request_actions(self): @@ -56,18 +56,18 @@ def test_formats_merged_pull_request(self): self.assertEqual( message, - '🔀 owner/repo — PR merged · #42 ' - "feat: add notifications · octocat", + "🔀 owner/repo — PR merged · [\\#42](https://github.com/owner/repo/pull/42) " + "*feat: add notifications* · octocat", ) - def test_escapes_pull_request_values_for_telegram_html(self): - self.pull_request["title"] = "fix: escape & text" - self.pull_request["author"] = {"login": "bot&name"} + def test_escapes_pull_request_values_for_telegram_markdown(self): + self.pull_request["title"] = "fix: escape [markup] & text!" + self.pull_request["author"] = {"login": "dependabot[bot]"} message = PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "opened") - self.assertIn("fix: escape <markup> & text", message) - self.assertIn("bot&name", message) + self.assertIn(r"*fix: escape \[markup\] & text\!*", message) + self.assertIn(r"dependabot\[bot\]", message) def test_open_pull_request_digest_excludes_drafts_and_reports_age(self): draft = dict(self.pull_request, number=41, isDraft=True) @@ -100,9 +100,9 @@ def test_open_pull_request_digest_lists_at_most_ten_pull_requests(self): message = PR_TELEGRAM.format_digest("owner/repo", pull_requests) self.assertIn("12 open PRs", message) - self.assertIn('pull/10">#10 feat: add notifications', message) - self.assertNotIn('pull/11">#11 feat: add notifications', message) - self.assertIn('href="https://github.com/owner/repo/pulls">...and 2 more', message) + self.assertIn(r"[\#10](https://github.com/owner/repo/pull/10) *feat: add notifications*", message) + self.assertNotIn(r"[\#11](https://github.com/owner/repo/pull/11) *feat: add notifications*", message) + self.assertIn(r"[\.\.\.and 2 more](https://github.com/owner/repo/pulls)", message) def test_open_pull_request_digest_stays_within_telegram_limit(self): pull_requests = [ @@ -118,7 +118,7 @@ def test_open_pull_request_digest_stays_within_telegram_limit(self): message = PR_TELEGRAM.format_digest("owner/repo", pull_requests) self.assertLessEqual(len(message), PR_TELEGRAM.TELEGRAM_MESSAGE_LIMIT) - self.assertIn("more", message) + self.assertIn("more]", message) def test_writes_multiline_github_output(self): with tempfile.NamedTemporaryFile() as output, patch.dict(os.environ, {"GITHUB_OUTPUT": output.name}): @@ -178,7 +178,7 @@ def test_pr_workflow_prepares_and_sends_supported_events(self): "$/.github/actions/send-telegram-message", ], ) - self.assertEqual(steps[-1]["with"]["parse-mode"], "HTML") + self.assertEqual(steps[-1]["with"]["parse-mode"], "MarkdownV2") def test_issue_workflow_only_sends_closed_issue_notification(self): workflow = self.load_workflow("notify-telegram-issue-shared.yml") From 27569a9b5c93e190da5ce37c53dff0ff06cf3e8a Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 22 Aug 2026 22:04:40 +0200 Subject: [PATCH 4/9] refactor: shorten Telegram ready message --- .github/actions/prepare-telegram-pr-message/prepare.py | 2 +- test/test_telegram_notifications.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/prepare-telegram-pr-message/prepare.py b/.github/actions/prepare-telegram-pr-message/prepare.py index 7248ad1..14351d3 100644 --- a/.github/actions/prepare-telegram-pr-message/prepare.py +++ b/.github/actions/prepare-telegram-pr-message/prepare.py @@ -38,7 +38,7 @@ def format_opened(repository, pull_request, event_action): return "" events = { - "ready_for_review": ("🆗", "PR ready for review"), + "ready_for_review": ("🆗", "PR ready"), "reopened": ("🆙", "PR reopened"), } emoji, event = events.get(event_action, ("🆕", "PR opened")) diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index feaa72e..8140837 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -43,7 +43,7 @@ def test_formats_opened_pull_request(self): ) def test_formats_ready_and_reopened_pull_request_actions(self): - self.assertIn("🆗 owner/repo — PR ready for review", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "ready_for_review")) + self.assertIn("🆗 owner/repo — PR ready", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "ready_for_review")) self.assertIn("🆙 owner/repo — PR reopened", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "reopened")) def test_skips_draft_pull_request(self): From 358908bf5f92d1dc4203e63820d5d532e1981378 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 22 Aug 2026 22:06:31 +0200 Subject: [PATCH 5/9] refactor: unify Telegram message separators --- .github/actions/prepare-telegram-pr-message/prepare.py | 6 +++--- test/test_telegram_notifications.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/actions/prepare-telegram-pr-message/prepare.py b/.github/actions/prepare-telegram-pr-message/prepare.py index 14351d3..9ed9571 100644 --- a/.github/actions/prepare-telegram-pr-message/prepare.py +++ b/.github/actions/prepare-telegram-pr-message/prepare.py @@ -43,14 +43,14 @@ def format_opened(repository, pull_request, event_action): } emoji, event = events.get(event_action, ("🆕", "PR opened")) return ( - f"{emoji} {escape_markdown(repository)} — {event} · {pull_request_link(pull_request)} " + f"{emoji} {escape_markdown(repository)} · {event} · {pull_request_link(pull_request)} " f"*{escape_markdown(pull_request['title'])}* · {escape_markdown(author_login(pull_request))}" ) def format_merged(repository, pull_request): return ( - f"🔀 {escape_markdown(repository)} — PR merged · {pull_request_link(pull_request)} " + f"🔀 {escape_markdown(repository)} · PR merged · {pull_request_link(pull_request)} " f"*{escape_markdown(pull_request['title'])}* · {escape_markdown(author_login(pull_request))}" ) @@ -66,7 +66,7 @@ def format_digest(repository, pull_requests, now=None): now = now or datetime.now(timezone.utc) count = len(pull_requests) - header = f"🔠 {escape_markdown(repository)} — {count} open {'PR' if count == 1 else 'PRs'}" + header = f"🔠 {escape_markdown(repository)} · {count} open {'PR' if count == 1 else 'PRs'}" lines = [] for pull_request in pull_requests[:DIGEST_PR_LIMIT]: age = max(0, (now - parse_github_time(pull_request["createdAt"])).days) diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index 8140837..ff1bfad 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -38,13 +38,13 @@ def test_formats_opened_pull_request(self): self.assertEqual( message, - "🆕 owner/repo — PR opened · [\\#42](https://github.com/owner/repo/pull/42) " + "🆕 owner/repo · PR opened · [\\#42](https://github.com/owner/repo/pull/42) " "*feat: add notifications* · octocat", ) def test_formats_ready_and_reopened_pull_request_actions(self): - self.assertIn("🆗 owner/repo — PR ready", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "ready_for_review")) - self.assertIn("🆙 owner/repo — PR reopened", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "reopened")) + self.assertIn("🆗 owner/repo · PR ready", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "ready_for_review")) + self.assertIn("🆙 owner/repo · PR reopened", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "reopened")) def test_skips_draft_pull_request(self): self.pull_request["isDraft"] = True @@ -56,7 +56,7 @@ def test_formats_merged_pull_request(self): self.assertEqual( message, - "🔀 owner/repo — PR merged · [\\#42](https://github.com/owner/repo/pull/42) " + "🔀 owner/repo · PR merged · [\\#42](https://github.com/owner/repo/pull/42) " "*feat: add notifications* · octocat", ) From 20cbf543898eefd5fef401e3604c37ef5ce33cf0 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 22 Aug 2026 23:11:52 +0200 Subject: [PATCH 6/9] refactor: remove Telegram PR message emoji --- .../actions/prepare-telegram-pr-message/prepare.py | 12 ++++++------ test/test_telegram_notifications.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/actions/prepare-telegram-pr-message/prepare.py b/.github/actions/prepare-telegram-pr-message/prepare.py index 9ed9571..cecb129 100644 --- a/.github/actions/prepare-telegram-pr-message/prepare.py +++ b/.github/actions/prepare-telegram-pr-message/prepare.py @@ -38,19 +38,19 @@ def format_opened(repository, pull_request, event_action): return "" events = { - "ready_for_review": ("🆗", "PR ready"), - "reopened": ("🆙", "PR reopened"), + "ready_for_review": "PR ready", + "reopened": "PR reopened", } - emoji, event = events.get(event_action, ("🆕", "PR opened")) + event = events.get(event_action, "PR opened") return ( - f"{emoji} {escape_markdown(repository)} · {event} · {pull_request_link(pull_request)} " + f"{escape_markdown(repository)} · {event} · {pull_request_link(pull_request)} " f"*{escape_markdown(pull_request['title'])}* · {escape_markdown(author_login(pull_request))}" ) def format_merged(repository, pull_request): return ( - f"🔀 {escape_markdown(repository)} · PR merged · {pull_request_link(pull_request)} " + f"{escape_markdown(repository)} · PR merged · {pull_request_link(pull_request)} " f"*{escape_markdown(pull_request['title'])}* · {escape_markdown(author_login(pull_request))}" ) @@ -66,7 +66,7 @@ def format_digest(repository, pull_requests, now=None): now = now or datetime.now(timezone.utc) count = len(pull_requests) - header = f"🔠 {escape_markdown(repository)} · {count} open {'PR' if count == 1 else 'PRs'}" + header = f"{escape_markdown(repository)} · {count} open {'PR' if count == 1 else 'PRs'}" lines = [] for pull_request in pull_requests[:DIGEST_PR_LIMIT]: age = max(0, (now - parse_github_time(pull_request["createdAt"])).days) diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index ff1bfad..9c6b5cc 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -38,13 +38,13 @@ def test_formats_opened_pull_request(self): self.assertEqual( message, - "🆕 owner/repo · PR opened · [\\#42](https://github.com/owner/repo/pull/42) " + "owner/repo · PR opened · [\\#42](https://github.com/owner/repo/pull/42) " "*feat: add notifications* · octocat", ) def test_formats_ready_and_reopened_pull_request_actions(self): - self.assertIn("🆗 owner/repo · PR ready", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "ready_for_review")) - self.assertIn("🆙 owner/repo · PR reopened", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "reopened")) + self.assertIn("owner/repo · PR ready", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "ready_for_review")) + self.assertIn("owner/repo · PR reopened", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "reopened")) def test_skips_draft_pull_request(self): self.pull_request["isDraft"] = True @@ -56,7 +56,7 @@ def test_formats_merged_pull_request(self): self.assertEqual( message, - "🔀 owner/repo · PR merged · [\\#42](https://github.com/owner/repo/pull/42) " + "owner/repo · PR merged · [\\#42](https://github.com/owner/repo/pull/42) " "*feat: add notifications* · octocat", ) @@ -78,7 +78,7 @@ def test_open_pull_request_digest_excludes_drafts_and_reports_age(self): ) self.assertIn("1 open PR", message) - self.assertTrue(message.startswith("🔠 owner/repo")) + self.assertTrue(message.startswith("owner/repo")) self.assertIn("octocat · 2d", message) self.assertNotIn("#41", message) self.assertNotIn("more", message) From bbff39f30d693954117dda4d612e7e214cc1d698 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 22 Aug 2026 23:12:37 +0200 Subject: [PATCH 7/9] refactor: remove Telegram issue message emoji --- .github/actions/prepare-telegram-issue-message/prepare.py | 2 +- test/test_telegram_notifications.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/prepare-telegram-issue-message/prepare.py b/.github/actions/prepare-telegram-issue-message/prepare.py index 6d85fde..da3ea7e 100644 --- a/.github/actions/prepare-telegram-issue-message/prepare.py +++ b/.github/actions/prepare-telegram-issue-message/prepare.py @@ -21,7 +21,7 @@ def truncate(value, limit): def format_closed(repository, issue, actor): lines = [ - f"🟢 {repository} — issue closed", + f"{repository} — issue closed", f"#{issue['number']} {issue['title']}", ] if body := truncate(issue.get("body") or "", ISSUE_BODY_LIMIT): diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index 9c6b5cc..441be9f 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -140,6 +140,7 @@ def test_closed_issue_includes_body(self): message = ISSUE_TELEGRAM.format_closed("owner/repo", issue, "octocat") + self.assertTrue(message.startswith("owner/repo — issue closed\n")) self.assertIn("#12 Move notifications\nResolution summary.\noctocat ·", message) def test_closed_issue_truncates_long_body(self): From e7f4badeb71f37b6f12c836d4baa15166b9ad588 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 22 Aug 2026 23:14:09 +0200 Subject: [PATCH 8/9] feat: align Telegram issue messages --- .../prepare-telegram-issue-message/prepare.py | 26 ++++++++++++++----- .../notify-telegram-issue-shared.yml | 1 + test/test_telegram_notifications.py | 26 ++++++++++++++++--- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.github/actions/prepare-telegram-issue-message/prepare.py b/.github/actions/prepare-telegram-issue-message/prepare.py index da3ea7e..eacbea2 100644 --- a/.github/actions/prepare-telegram-issue-message/prepare.py +++ b/.github/actions/prepare-telegram-issue-message/prepare.py @@ -5,6 +5,7 @@ import uuid ISSUE_BODY_LIMIT = 500 +MARKDOWN_V2_SPECIAL_CHARACTERS = frozenset("_*[]()~`>#+-=|{}.!\\") def gh_json(*arguments): @@ -13,21 +14,32 @@ def gh_json(*arguments): def truncate(value, limit): - value = value.strip() + value = " ".join(value.split()) if len(value) <= limit: return value return value[: limit - 3].rstrip() + "..." +def escape_markdown(value): + return "".join(f"\\{character}" if character in MARKDOWN_V2_SPECIAL_CHARACTERS else character for character in str(value)) + + +def escape_link_url(value): + return str(value).replace("\\", "\\\\").replace(")", "\\)") + + def format_closed(repository, issue, actor): - lines = [ - f"{repository} — issue closed", - f"#{issue['number']} {issue['title']}", + number = escape_markdown(f"#{issue['number']}") + link = f"[{number}]({escape_link_url(issue['url'])})" + parts = [ + escape_markdown(repository), + "issue closed", + f"{link} *{escape_markdown(issue['title'])}*", ] if body := truncate(issue.get("body") or "", ISSUE_BODY_LIMIT): - lines.append(body) - lines.append(f"{actor} · {issue['url']}") - return "\n".join(lines) + parts.append(escape_markdown(body)) + parts.append(escape_markdown(actor)) + return " · ".join(parts) def write_output(message): diff --git a/.github/workflows/notify-telegram-issue-shared.yml b/.github/workflows/notify-telegram-issue-shared.yml index 41878d5..1e5ecc0 100644 --- a/.github/workflows/notify-telegram-issue-shared.yml +++ b/.github/workflows/notify-telegram-issue-shared.yml @@ -23,3 +23,4 @@ jobs: message: ${{ steps.prepare.outputs.message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }} + parse-mode: MarkdownV2 diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index 441be9f..b01e6d8 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -140,8 +140,11 @@ def test_closed_issue_includes_body(self): message = ISSUE_TELEGRAM.format_closed("owner/repo", issue, "octocat") - self.assertTrue(message.startswith("owner/repo — issue closed\n")) - self.assertIn("#12 Move notifications\nResolution summary.\noctocat ·", message) + self.assertEqual( + message, + "owner/repo · issue closed · [\\#12](https://github.com/owner/repo/issues/12) " + "*Move notifications* · Resolution summary\\. · octocat", + ) def test_closed_issue_truncates_long_body(self): issue = { @@ -152,10 +155,26 @@ def test_closed_issue_truncates_long_body(self): } message = ISSUE_TELEGRAM.format_closed("owner/repo", issue, "octocat") - body = message.splitlines()[2] + body = ISSUE_TELEGRAM.truncate(issue["body"], ISSUE_TELEGRAM.ISSUE_BODY_LIMIT) self.assertEqual(len(body), ISSUE_TELEGRAM.ISSUE_BODY_LIMIT) self.assertTrue(body.endswith("...")) + self.assertIn(r"\.\.\. · octocat", message) + + def test_closed_issue_normalizes_body_and_escapes_markdown(self): + issue = { + "number": 12, + "title": "Fix [alerts]", + "body": "First line.\n\nSecond *line*.", + "url": "https://github.com/owner/repo/issues/12", + } + + message = ISSUE_TELEGRAM.format_closed("owner/repo", issue, "dependabot[bot]") + + self.assertNotIn("\n", message) + self.assertIn(r"*Fix \[alerts\]*", message) + self.assertIn(r"First line\. Second \*line\*\.", message) + self.assertTrue(message.endswith(r"dependabot\[bot\]")) class TelegramWorkflowTest(unittest.TestCase): @@ -195,6 +214,7 @@ def test_issue_workflow_only_sends_closed_issue_notification(self): "$/.github/actions/send-telegram-message", ], ) + self.assertEqual(job["steps"][-1]["with"]["parse-mode"], "MarkdownV2") def test_baseline_caller_passes_only_explicit_telegram_secrets(self): path = BASELINE_ROOT / ".github" / "workflows" / "notify-telegram-pr.yml" From 2c4e0de9f9bc11739fa038cc9c709ffc331c8930 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 22 Aug 2026 23:18:58 +0200 Subject: [PATCH 9/9] refactor: simplify Telegram notification headers --- .../prepare-telegram-issue-message/prepare.py | 48 +++++++++++++++---- .../prepare-telegram-pr-message/prepare.py | 14 +++--- test/test_telegram_notifications.py | 40 ++++++++++------ 3 files changed, 72 insertions(+), 30 deletions(-) diff --git a/.github/actions/prepare-telegram-issue-message/prepare.py b/.github/actions/prepare-telegram-issue-message/prepare.py index eacbea2..a5474cf 100644 --- a/.github/actions/prepare-telegram-issue-message/prepare.py +++ b/.github/actions/prepare-telegram-issue-message/prepare.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import json import os +import re import subprocess import uuid @@ -14,7 +15,7 @@ def gh_json(*arguments): def truncate(value, limit): - value = " ".join(value.split()) + value = value.strip() if len(value) <= limit: return value return value[: limit - 3].rstrip() + "..." @@ -28,18 +29,47 @@ def escape_link_url(value): return str(value).replace("\\", "\\\\").replace(")", "\\)") +def body_excerpt(value, limit=ISSUE_BODY_LIMIT): + lines = value.strip().splitlines() + while lines and not lines[0].strip(): + lines.pop(0) + if not lines: + return "" + + heading = re.fullmatch(r"\s{0,3}#{1,6}\s+(.+?)\s*#*\s*", lines[0]) + if heading: + lines = lines[1:] + while lines and not lines[0].strip(): + lines.pop(0) + paragraph = [] + for line in lines: + if not line.strip(): + break + paragraph.append(line.strip()) + parts = [heading.group(1), " ".join(paragraph)] + return truncate("\n".join(part for part in parts if part), limit) + + paragraph = [] + for line in lines: + if not line.strip(): + break + paragraph.append(line.strip()) + return truncate(" ".join(paragraph), limit) + + def format_closed(repository, issue, actor): - number = escape_markdown(f"#{issue['number']}") - link = f"[{number}]({escape_link_url(issue['url'])})" + label = escape_markdown(f"Issue #{issue['number']}") + link = f"[{label}]({escape_link_url(issue['url'])})" parts = [ escape_markdown(repository), - "issue closed", - f"{link} *{escape_markdown(issue['title'])}*", + f"{link} closed", + f"*{escape_markdown(issue['title'])}*", + escape_markdown(actor), ] - if body := truncate(issue.get("body") or "", ISSUE_BODY_LIMIT): - parts.append(escape_markdown(body)) - parts.append(escape_markdown(actor)) - return " · ".join(parts) + message = " · ".join(parts) + if excerpt := body_excerpt(issue.get("body") or ""): + message += f"\n{escape_markdown(excerpt)}" + return message def write_output(message): diff --git a/.github/actions/prepare-telegram-pr-message/prepare.py b/.github/actions/prepare-telegram-pr-message/prepare.py index cecb129..5ab438a 100644 --- a/.github/actions/prepare-telegram-pr-message/prepare.py +++ b/.github/actions/prepare-telegram-pr-message/prepare.py @@ -28,9 +28,9 @@ def escape_link_url(value): def pull_request_link(pull_request): - number = escape_markdown(f"#{pull_request['number']}") + label = escape_markdown(f"PR #{pull_request['number']}") url = escape_link_url(pull_request["url"]) - return f"[{number}]({url})" + return f"[{label}]({url})" def format_opened(repository, pull_request, event_action): @@ -38,19 +38,19 @@ def format_opened(repository, pull_request, event_action): return "" events = { - "ready_for_review": "PR ready", - "reopened": "PR reopened", + "ready_for_review": "ready", + "reopened": "reopened", } - event = events.get(event_action, "PR opened") + event = events.get(event_action, "opened") return ( - f"{escape_markdown(repository)} · {event} · {pull_request_link(pull_request)} " + f"{escape_markdown(repository)} · {pull_request_link(pull_request)} {event} · " f"*{escape_markdown(pull_request['title'])}* · {escape_markdown(author_login(pull_request))}" ) def format_merged(repository, pull_request): return ( - f"{escape_markdown(repository)} · PR merged · {pull_request_link(pull_request)} " + f"{escape_markdown(repository)} · {pull_request_link(pull_request)} merged · " f"*{escape_markdown(pull_request['title'])}* · {escape_markdown(author_login(pull_request))}" ) diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index b01e6d8..1cc1355 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -38,13 +38,13 @@ def test_formats_opened_pull_request(self): self.assertEqual( message, - "owner/repo · PR opened · [\\#42](https://github.com/owner/repo/pull/42) " + "owner/repo · [PR \\#42](https://github.com/owner/repo/pull/42) opened · " "*feat: add notifications* · octocat", ) def test_formats_ready_and_reopened_pull_request_actions(self): - self.assertIn("owner/repo · PR ready", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "ready_for_review")) - self.assertIn("owner/repo · PR reopened", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "reopened")) + self.assertIn("[PR \\#42](https://github.com/owner/repo/pull/42) ready", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "ready_for_review")) + self.assertIn("[PR \\#42](https://github.com/owner/repo/pull/42) reopened", PR_TELEGRAM.format_opened("owner/repo", self.pull_request, "reopened")) def test_skips_draft_pull_request(self): self.pull_request["isDraft"] = True @@ -56,7 +56,7 @@ def test_formats_merged_pull_request(self): self.assertEqual( message, - "owner/repo · PR merged · [\\#42](https://github.com/owner/repo/pull/42) " + "owner/repo · [PR \\#42](https://github.com/owner/repo/pull/42) merged · " "*feat: add notifications* · octocat", ) @@ -100,8 +100,8 @@ def test_open_pull_request_digest_lists_at_most_ten_pull_requests(self): message = PR_TELEGRAM.format_digest("owner/repo", pull_requests) self.assertIn("12 open PRs", message) - self.assertIn(r"[\#10](https://github.com/owner/repo/pull/10) *feat: add notifications*", message) - self.assertNotIn(r"[\#11](https://github.com/owner/repo/pull/11) *feat: add notifications*", message) + self.assertIn(r"[PR \#10](https://github.com/owner/repo/pull/10) *feat: add notifications*", message) + self.assertNotIn(r"[PR \#11](https://github.com/owner/repo/pull/11) *feat: add notifications*", message) self.assertIn(r"[\.\.\.and 2 more](https://github.com/owner/repo/pulls)", message) def test_open_pull_request_digest_stays_within_telegram_limit(self): @@ -142,8 +142,8 @@ def test_closed_issue_includes_body(self): self.assertEqual( message, - "owner/repo · issue closed · [\\#12](https://github.com/owner/repo/issues/12) " - "*Move notifications* · Resolution summary\\. · octocat", + "owner/repo · [Issue \\#12](https://github.com/owner/repo/issues/12) closed · " + "*Move notifications* · octocat\nResolution summary\\.", ) def test_closed_issue_truncates_long_body(self): @@ -159,22 +159,34 @@ def test_closed_issue_truncates_long_body(self): self.assertEqual(len(body), ISSUE_TELEGRAM.ISSUE_BODY_LIMIT) self.assertTrue(body.endswith("...")) - self.assertIn(r"\.\.\. · octocat", message) + self.assertTrue(message.endswith(r"\.\.\.")) - def test_closed_issue_normalizes_body_and_escapes_markdown(self): + def test_closed_issue_uses_only_first_body_paragraph(self): issue = { "number": 12, "title": "Fix [alerts]", - "body": "First line.\n\nSecond *line*.", + "body": "First line.\nContinued *line*.\n\nIgnored paragraph.", "url": "https://github.com/owner/repo/issues/12", } message = ISSUE_TELEGRAM.format_closed("owner/repo", issue, "dependabot[bot]") - self.assertNotIn("\n", message) self.assertIn(r"*Fix \[alerts\]*", message) - self.assertIn(r"First line\. Second \*line\*\.", message) - self.assertTrue(message.endswith(r"dependabot\[bot\]")) + self.assertTrue(message.endswith("dependabot\\[bot\\]\nFirst line\\. Continued \\*line\\*\\.")) + self.assertNotIn("Ignored", message) + + def test_closed_issue_includes_leading_heading_and_first_paragraph(self): + issue = { + "number": 12, + "title": "Move notifications", + "body": "## Resolution\n\nFirst line.\nContinued line.\n\nIgnored paragraph.", + "url": "https://github.com/owner/repo/issues/12", + } + + message = ISSUE_TELEGRAM.format_closed("owner/repo", issue, "octocat") + + self.assertTrue(message.endswith("\nResolution\nFirst line\\. Continued line\\.")) + self.assertNotIn("Ignored", message) class TelegramWorkflowTest(unittest.TestCase):