From 297f1408765a9f53f680c9eb71f1344c81fbae13 Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Fri, 26 Dec 2025 15:20:02 +0300 Subject: [PATCH 1/3] refactor(infrastructure): descanso clients --- README.md | 2 +- notifier/__main__.py | 25 ++-- notifier/application/interactors.py | 39 +++++- notifier/application/interfaces.py | 11 +- notifier/infrastructure/github_gateway.py | 133 ++++++++++++-------- notifier/infrastructure/telegram_gateway.py | 71 ++++------- requirements.txt | 2 + 7 files changed, 170 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 498adf5..9af5c34 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ on: permissions: issues: read - pull_request: read + pull_requests: read jobs: notify: diff --git a/notifier/__main__.py b/notifier/__main__.py index 45b4baa..240ffc1 100644 --- a/notifier/__main__.py +++ b/notifier/__main__.py @@ -24,21 +24,15 @@ def get_interactor(url: str) -> type[SendIssue] | type[SendPR]: if __name__ == "__main__": - html_template = os.environ.get("HTML_TEMPLATE", "").strip() + bot_token = os.environ["TELEGRAM_BOT_TOKEN"] - telegram_gateway = TelegramGateway( - chat_id=os.environ["TELEGRAM_CHAT_ID"], - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - attempt_count=int(os.environ["ATTEMPT_COUNT"]), - message_thread_id=os.environ.get("TELEGRAM_MESSAGE_THREAD_ID"), - ) + attempt_count = os.environ["ATTEMPT_COUNT"] - event_url = os.environ["EVENT_URL"] + telegram_gateway = TelegramGateway(bot_token, int(attempt_count)) - github_gateway = GithubGateway( - token=(os.environ.get("GITHUB_TOKEN") or "").strip(), - event_url=event_url, - ) + gh_token = os.environ["GITHUB_TOKEN"] + + github_gateway = GithubGateway(gh_token) custom_labels = os.environ.get("CUSTOM_LABELS", "").split(",") if custom_labels == [""]: @@ -49,7 +43,14 @@ def get_interactor(url: str) -> type[SendIssue] | type[SendPR]: join_input_with_list=os.environ.get("JOIN_INPUT_WITH_LIST") == "1", ) + event_url = os.environ["EVENT_URL"] + + html_template = os.environ.get("HTML_TEMPLATE", "").strip() + interactor = get_interactor(event_url)( + bot_token=os.environ["TELEGRAM_BOT_TOKEN"], + chat_id=os.environ["TELEGRAM_CHAT_ID"], + thread_id=os.environ.get("TELEGRAM_MESSAGE_THREAD_ID"), template=html_template, github=github_gateway, telegram=telegram_gateway, diff --git a/notifier/application/interactors.py b/notifier/application/interactors.py index d53a7b1..d78edba 100644 --- a/notifier/application/interactors.py +++ b/notifier/application/interactors.py @@ -8,7 +8,6 @@ TG_MESSAGE_LIMIT: typing.Final = 4096 - ISSUE_TEMPLATE: typing.Final = ( "🚀 New issue to {repository} by @{user}
" "📝 {title} (#{id})

" @@ -32,11 +31,17 @@ class SendIssue: def __init__( self, template: str, + bot_token: str, + chat_id: str, + thread_id: str | None, github: interfaces.Github, telegram: interfaces.Telegram, render_service: RenderService, ) -> None: self._template = template or ISSUE_TEMPLATE + self._bot_token = bot_token + self._chat_id = chat_id + self._thread_id = thread_id self._github = github self._telegram = telegram self._render_service = render_service @@ -54,8 +59,19 @@ def handler(self) -> None: base_url="https://github.com", ) + for e in render_result.entities: + e.pop("language", None) + + tg_body=interfaces.TgPayload( + text=render_result.text, + entities=render_result.entities, + disable_web_page_preview=True, + chat_id=self._chat_id, + message_thread_id = self._thread_id + ) + if len(render_result.text) <= TG_MESSAGE_LIMIT: - return self._telegram.send_message(render_result) + return self._telegram.send_message(tg_body) message_without_description = self._create_message(issue, "

", labels) @@ -81,11 +97,17 @@ class SendPR: def __init__( self, template: str, + bot_token: str, + chat_id: str, + thread_id: str | None, github: interfaces.Github, telegram: interfaces.Telegram, render_service: RenderService, ) -> None: self._template = template or PR_TEMPLATE + self._bot_token = bot_token + self._chat_id = chat_id + self._thread_id = thread_id self._github = github self._telegram = telegram self._render_service = render_service @@ -103,8 +125,19 @@ def handler(self) -> None: base_url="https://github.com", ) + for e in render_result.entities: + e.pop("language", None) + + tg_body = interfaces.TgPayload( + text=render_result.text, + entities=render_result.entities, + disable_web_page_preview=True, + chat_id=self._chat_id, + message_thread_id = self._thread_id + ) + if len(render_result.text) <= TG_MESSAGE_LIMIT: - return self._telegram.send_message(render_result) + return self._telegram.send_message(tg_body) message_without_description = self._create_message(pr, "

", labels) diff --git a/notifier/application/interfaces.py b/notifier/application/interfaces.py index 4e815f4..e3cee2a 100644 --- a/notifier/application/interfaces.py +++ b/notifier/application/interfaces.py @@ -1,10 +1,19 @@ import abc +from dataclasses import dataclass import typing import sulguk from notifier.domain.entities import PullRequest, Issue +@dataclass +class TgPayload: + text: str + entities: list[sulguk.data.MessageEntity] + disable_web_page_preview: bool + chat_id: str + message_thread_id: str | None + class Github(typing.Protocol): @abc.abstractmethod @@ -16,4 +25,4 @@ def get_pull_request(self) -> PullRequest: ... class Telegram(typing.Protocol): @abc.abstractmethod - def send_message(self, render_result: sulguk.RenderResult) -> None: ... + def send_message(self, body: TgPayload) -> None: ... diff --git a/notifier/infrastructure/github_gateway.py b/notifier/infrastructure/github_gateway.py index 06db82c..89a9386 100644 --- a/notifier/infrastructure/github_gateway.py +++ b/notifier/infrastructure/github_gateway.py @@ -1,57 +1,88 @@ -import requests +import os +from typing import Any + +from adaptix import P, Retort, loader, name_mapping, Chain +from descanso import RestBuilder +from descanso import request_transformers as rt +from descanso.http.requests import RequestsClient +from requests import Session from notifier.application import interfaces from notifier.domain.entities import Issue, PullRequest +issue_recipe = [ + name_mapping( + Issue, + map={ + "id": "number", + "url": "html_url", + "user": ["user", "login"], + "body": "body_html", + }, + ), +] + +pr_recipe = [ + name_mapping( + PullRequest, + map={ + "id": "number", + "url": "html_url", + "user": ["user", "login"], + "head_ref": ["head", "label"], + "base_ref": ["base", "ref"], + "repository": ["base", "repo", "full_name"], + "body": "body_html", + }, + ), +] + + +def body_pre_loader(data: dict[str, Any]) -> dict[str, Any]: + if "body_html" not in data: + data["body_html"] = "" + return data + + +gh_recipe = [ + *issue_recipe, + *pr_recipe, + loader(P[Issue, PullRequest], body_pre_loader, Chain.FIRST), + loader(P[Issue, PullRequest].labels, lambda labels: [label["name"] for label in labels]) +] + +rest = RestBuilder( + request_body_dumper=Retort(), + response_body_loader=Retort(recipe=gh_recipe), + query_param_dumper=Retort(), +) -class GithubGateway(interfaces.Github): - def __init__(self, token: str, event_url: str) -> None: +headers = ( + rt.Header("Accept", "application/vnd.github.v3.html+json"), + rt.Header("X-GitHub-Api-Version", "2022-11-28"), + rt.Header("Authorization", "Bearer {self._token}"), +) + + +def get_event_url() -> str: + event_url = os.environ["EVENT_URL"] + return event_url + + +class GithubGateway(RequestsClient, interfaces.Github): + def __init__( + self, + token: str, + base_url: str = "", + session: Session | None = None + ) -> None: self._token = token - self._url = event_url - - def get_issue(self) -> Issue: - headers = { - "Accept": "application/vnd.github.v3.html+json", - "X-GitHub-Api-Version": "2022-11-28", - "Authorization": f"Bearer {self._token}", - } - - response = requests.get(self._url, headers=headers, timeout=30) - response.raise_for_status() - - data = response.json() - - return Issue( - id=data["number"], - title=data["title"], - labels=[label["name"] for label in data["labels"]], - url=(data["html_url"] or "").strip(), - user=data["user"]["login"], - body=(data.get("body_html", "") or "").strip(), - ) - - def get_pull_request(self) -> PullRequest: - headers = { - "Accept": "application/vnd.github.v3.html+json", - "X-GitHub-Api-Version": "2022-11-28", - "Authorization": f"Bearer {self._token}", - } - - response = requests.get(self._url, headers=headers, timeout=30) - response.raise_for_status() - - data = response.json() - - return PullRequest( - id=data["number"], - title=data["title"], - labels=[label["name"] for label in data["labels"]], - url=data["html_url"], - user=data["user"]["login"], - body=(data.get("body_html", "") or "").strip(), - additions=data["additions"], - deletions=data["deletions"], - head_ref=data["head"]["label"], - base_ref=data["base"]["ref"], - repository=data["base"]["repo"]["full_name"], - ) + super().__init__(base_url, session or Session(), headers) + + @rest.get(get_event_url) + def get_issue(self) -> Issue: # type: ignore[empty-body] + pass + + @rest.get(get_event_url) + def get_pull_request(self) -> PullRequest: # type: ignore[empty-body] + pass diff --git a/notifier/infrastructure/telegram_gateway.py b/notifier/infrastructure/telegram_gateway.py index 95bab39..4c580ba 100644 --- a/notifier/infrastructure/telegram_gateway.py +++ b/notifier/infrastructure/telegram_gateway.py @@ -1,53 +1,34 @@ -import sys -import time +from typing import Any -import requests -import sulguk +from adaptix import Retort +from requests import Session +from requests.adapters import HTTPAdapter +from descanso import RestBuilder +from descanso.http.requests import RequestsClient from notifier.application import interfaces +rest = RestBuilder( + request_body_dumper=Retort(), + response_body_loader=Retort(), + query_param_dumper=Retort(), +) + + +class TelegramGateway(RequestsClient, interfaces.Telegram): -class TelegramGateway(interfaces.Telegram): def __init__( self, - chat_id: str, - bot_token: str, - attempt_count: int, - message_thread_id: str | int | None, + token: str, + attemp_count: int, + base_url: str = "https://api.telegram.org", + session: Session | None = None, ) -> None: - self._chat_id = chat_id - self._bot_token = bot_token - self._attempt_count = attempt_count - self._message_thread_id = message_thread_id - - def send_message(self, render_result: sulguk.RenderResult) -> None: - count = 0 - payload = self._create_payload(render_result) - url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage" - while count < self._attempt_count: - response = requests.post(url, json=payload, timeout=30) - try: - response.raise_for_status() - except requests.exceptions.HTTPError: - print(response.content, file=sys.stderr) - count += 1 - time.sleep(count * 2) - else: - print(response.json(), file=sys.stdout) - return - - def _create_payload(self, render_result: sulguk.RenderResult) -> dict: - for e in render_result.entities: - e.pop("language", None) - - payload = { - "text": render_result.text, - "entities": render_result.entities, - "disable_web_page_preview": True, - } - payload["chat_id"] = self._chat_id - - if self._message_thread_id is not None: - payload["message_thread_id"] = self._message_thread_id - - return payload + self._token = token + tg_session = session or Session() + tg_session.mount("https", HTTPAdapter(max_retries=attemp_count)) + super().__init__(base_url, tg_session) + + @rest.post("/bot{self._token}/sendMessage") + def send_message(self, body: interfaces.TgPayload) -> Any: + pass diff --git a/requirements.txt b/requirements.txt index 4617dbf..6cfcf4d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ sulguk==0.10.1 requests==2.32.5 beautifulsoup4==4.14.2 lxml==6.0.2 +descanso==0.7.1 +adaptix==3.0.0b11 \ No newline at end of file From 98961f8fd015209d50eb0a528761678886d083d3 Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Mon, 12 Jan 2026 16:25:11 +0300 Subject: [PATCH 2/3] fix: fixed wrong dependencies --- notifier/__main__.py | 18 +++----- notifier/application/interactors.py | 24 +++------- notifier/application/interfaces.py | 3 +- notifier/infrastructure/github_gateway.py | 51 +++++++++++---------- notifier/infrastructure/telegram_gateway.py | 2 +- 5 files changed, 42 insertions(+), 56 deletions(-) diff --git a/notifier/__main__.py b/notifier/__main__.py index 240ffc1..03d31f9 100644 --- a/notifier/__main__.py +++ b/notifier/__main__.py @@ -25,40 +25,36 @@ def get_interactor(url: str) -> type[SendIssue] | type[SendPR]: if __name__ == "__main__": bot_token = os.environ["TELEGRAM_BOT_TOKEN"] - attempt_count = os.environ["ATTEMPT_COUNT"] telegram_gateway = TelegramGateway(bot_token, int(attempt_count)) gh_token = os.environ["GITHUB_TOKEN"] + event_url = os.environ["EVENT_URL"] - github_gateway = GithubGateway(gh_token) + github_gateway = GithubGateway(gh_token, event_url) + html_template = os.environ.get("HTML_TEMPLATE", "").strip() custom_labels = os.environ.get("CUSTOM_LABELS", "").split(",") if custom_labels == [""]: custom_labels = [] - render_service = RenderService( custom_labels=custom_labels, join_input_with_list=os.environ.get("JOIN_INPUT_WITH_LIST") == "1", ) - event_url = os.environ["EVENT_URL"] - - html_template = os.environ.get("HTML_TEMPLATE", "").strip() - interactor = get_interactor(event_url)( - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - chat_id=os.environ["TELEGRAM_CHAT_ID"], - thread_id=os.environ.get("TELEGRAM_MESSAGE_THREAD_ID"), template=html_template, github=github_gateway, telegram=telegram_gateway, render_service=render_service, ) + chat_id = os.environ["TELEGRAM_CHAT_ID"] + message_thread_id=os.environ.get("TELEGRAM_MESSAGE_THREAD_ID") + try: - interactor.handler() + interactor.handler(chat_id, message_thread_id) except Exception as e: print(f"Error processing event: {e}", file=sys.stderr) sys.exit(1) diff --git a/notifier/application/interactors.py b/notifier/application/interactors.py index d78edba..9a0cadf 100644 --- a/notifier/application/interactors.py +++ b/notifier/application/interactors.py @@ -31,22 +31,16 @@ class SendIssue: def __init__( self, template: str, - bot_token: str, - chat_id: str, - thread_id: str | None, github: interfaces.Github, telegram: interfaces.Telegram, render_service: RenderService, ) -> None: self._template = template or ISSUE_TEMPLATE - self._bot_token = bot_token - self._chat_id = chat_id - self._thread_id = thread_id self._github = github self._telegram = telegram self._render_service = render_service - def handler(self) -> None: + def handler(self, chat_id: str, thread_id: str | None) -> None: issue = self._github.get_issue() labels = self._render_service.format_labels(issue.labels) @@ -66,8 +60,8 @@ def handler(self) -> None: text=render_result.text, entities=render_result.entities, disable_web_page_preview=True, - chat_id=self._chat_id, - message_thread_id = self._thread_id + chat_id=chat_id, + message_thread_id=thread_id, ) if len(render_result.text) <= TG_MESSAGE_LIMIT: @@ -97,22 +91,16 @@ class SendPR: def __init__( self, template: str, - bot_token: str, - chat_id: str, - thread_id: str | None, github: interfaces.Github, telegram: interfaces.Telegram, render_service: RenderService, ) -> None: self._template = template or PR_TEMPLATE - self._bot_token = bot_token - self._chat_id = chat_id - self._thread_id = thread_id self._github = github self._telegram = telegram self._render_service = render_service - def handler(self) -> None: + def handler(self, chat_id: str, thread_id: str | None) -> None: pr = self._github.get_pull_request() labels = self._render_service.format_labels(pr.labels) @@ -132,8 +120,8 @@ def handler(self) -> None: text=render_result.text, entities=render_result.entities, disable_web_page_preview=True, - chat_id=self._chat_id, - message_thread_id = self._thread_id + chat_id=chat_id, + message_thread_id=thread_id, ) if len(render_result.text) <= TG_MESSAGE_LIMIT: diff --git a/notifier/application/interfaces.py b/notifier/application/interfaces.py index e3cee2a..8512365 100644 --- a/notifier/application/interfaces.py +++ b/notifier/application/interfaces.py @@ -6,6 +6,7 @@ from notifier.domain.entities import PullRequest, Issue + @dataclass class TgPayload: text: str @@ -25,4 +26,4 @@ def get_pull_request(self) -> PullRequest: ... class Telegram(typing.Protocol): @abc.abstractmethod - def send_message(self, body: TgPayload) -> None: ... + def send_message(self, body: TgPayload) -> typing.Any: ... diff --git a/notifier/infrastructure/github_gateway.py b/notifier/infrastructure/github_gateway.py index 89a9386..d3a0db8 100644 --- a/notifier/infrastructure/github_gateway.py +++ b/notifier/infrastructure/github_gateway.py @@ -10,6 +10,13 @@ from notifier.application import interfaces from notifier.domain.entities import Issue, PullRequest + +def body_pre_loader(data: dict[str, Any]) -> dict[str, Any]: + if "body_html" not in data: + data["body_html"] = "" + return data + + issue_recipe = [ name_mapping( Issue, @@ -20,6 +27,8 @@ "body": "body_html", }, ), + loader(P[Issue], body_pre_loader, Chain.FIRST), + loader(P[Issue].labels, lambda labels: [label["name"] for label in labels]), ] pr_recipe = [ @@ -35,20 +44,14 @@ "body": "body_html", }, ), + loader(P[PullRequest], body_pre_loader, Chain.FIRST), + loader(P[PullRequest].labels, lambda labels: [label["name"] for label in labels]), ] - -def body_pre_loader(data: dict[str, Any]) -> dict[str, Any]: - if "body_html" not in data: - data["body_html"] = "" - return data - - gh_recipe = [ *issue_recipe, *pr_recipe, - loader(P[Issue, PullRequest], body_pre_loader, Chain.FIRST), - loader(P[Issue, PullRequest].labels, lambda labels: [label["name"] for label in labels]) + ] rest = RestBuilder( @@ -57,32 +60,30 @@ def body_pre_loader(data: dict[str, Any]) -> dict[str, Any]: query_param_dumper=Retort(), ) -headers = ( - rt.Header("Accept", "application/vnd.github.v3.html+json"), - rt.Header("X-GitHub-Api-Version", "2022-11-28"), - rt.Header("Authorization", "Bearer {self._token}"), -) - - -def get_event_url() -> str: - event_url = os.environ["EVENT_URL"] - return event_url - class GithubGateway(RequestsClient, interfaces.Github): def __init__( self, token: str, - base_url: str = "", + event_url: str, session: Session | None = None ) -> None: self._token = token - super().__init__(base_url, session or Session(), headers) - - @rest.get(get_event_url) + self._event_url = event_url + super().__init__( + "", + session or Session(), + ( + rt.Header("Accept", "application/vnd.github.v3.html+json"), + rt.Header("X-GitHub-Api-Version", "2022-11-28"), + rt.Header("Authorization", f"Bearer {self._token}"), + ), + ) + + @rest.get("{self._event_url}") def get_issue(self) -> Issue: # type: ignore[empty-body] pass - @rest.get(get_event_url) + @rest.get("{self._event_url}") def get_pull_request(self) -> PullRequest: # type: ignore[empty-body] pass diff --git a/notifier/infrastructure/telegram_gateway.py b/notifier/infrastructure/telegram_gateway.py index 4c580ba..24887cc 100644 --- a/notifier/infrastructure/telegram_gateway.py +++ b/notifier/infrastructure/telegram_gateway.py @@ -30,5 +30,5 @@ def __init__( super().__init__(base_url, tg_session) @rest.post("/bot{self._token}/sendMessage") - def send_message(self, body: interfaces.TgPayload) -> Any: + def send_message(self, body: interfaces.TgPayload) -> Any: # type: ignore[empty-body] pass From c04654981a1befc3412a0f2b13d60ece8eab0866 Mon Sep 17 00:00:00 2001 From: Panteleev Aleksandr Sergeevich Date: Mon, 12 Jan 2026 17:43:11 +0300 Subject: [PATCH 3/3] fix: move TgPayload to infra --- notifier/application/interactors.py | 5 +++-- notifier/application/interfaces.py | 13 +------------ notifier/infrastructure/telegram_gateway.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/notifier/application/interactors.py b/notifier/application/interactors.py index 9a0cadf..f915533 100644 --- a/notifier/application/interactors.py +++ b/notifier/application/interactors.py @@ -5,6 +5,7 @@ from notifier.application import interfaces from notifier.application.services import RenderService from notifier.domain.entities import Issue, PullRequest +from notifier.infrastructure.telegram_gateway import TgPayload TG_MESSAGE_LIMIT: typing.Final = 4096 @@ -56,7 +57,7 @@ def handler(self, chat_id: str, thread_id: str | None) -> None: for e in render_result.entities: e.pop("language", None) - tg_body=interfaces.TgPayload( + tg_body=TgPayload( text=render_result.text, entities=render_result.entities, disable_web_page_preview=True, @@ -116,7 +117,7 @@ def handler(self, chat_id: str, thread_id: str | None) -> None: for e in render_result.entities: e.pop("language", None) - tg_body = interfaces.TgPayload( + tg_body = TgPayload( text=render_result.text, entities=render_result.entities, disable_web_page_preview=True, diff --git a/notifier/application/interfaces.py b/notifier/application/interfaces.py index 8512365..9b7fcdc 100644 --- a/notifier/application/interfaces.py +++ b/notifier/application/interfaces.py @@ -1,19 +1,8 @@ import abc -from dataclasses import dataclass import typing -import sulguk - from notifier.domain.entities import PullRequest, Issue - - -@dataclass -class TgPayload: - text: str - entities: list[sulguk.data.MessageEntity] - disable_web_page_preview: bool - chat_id: str - message_thread_id: str | None +from notifier.infrastructure.telegram_gateway import TgPayload class Github(typing.Protocol): diff --git a/notifier/infrastructure/telegram_gateway.py b/notifier/infrastructure/telegram_gateway.py index 24887cc..2b2fe1e 100644 --- a/notifier/infrastructure/telegram_gateway.py +++ b/notifier/infrastructure/telegram_gateway.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Any from adaptix import Retort @@ -5,6 +6,7 @@ from requests.adapters import HTTPAdapter from descanso import RestBuilder from descanso.http.requests import RequestsClient +import sulguk from notifier.application import interfaces @@ -15,6 +17,15 @@ ) +@dataclass +class TgPayload: + text: str + entities: list[sulguk.data.MessageEntity] + disable_web_page_preview: bool + chat_id: str + message_thread_id: str | None + + class TelegramGateway(RequestsClient, interfaces.Telegram): def __init__(