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..03d31f9 100644 --- a/notifier/__main__.py +++ b/notifier/__main__.py @@ -24,26 +24,20 @@ 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"] + attempt_count = os.environ["ATTEMPT_COUNT"] - 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"), - ) + telegram_gateway = TelegramGateway(bot_token, int(attempt_count)) + gh_token = os.environ["GITHUB_TOKEN"] event_url = os.environ["EVENT_URL"] - github_gateway = GithubGateway( - token=(os.environ.get("GITHUB_TOKEN") or "").strip(), - event_url=event_url, - ) + 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", @@ -56,8 +50,11 @@ def get_interactor(url: str) -> type[SendIssue] | type[SendPR]: 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 d53a7b1..f915533 100644 --- a/notifier/application/interactors.py +++ b/notifier/application/interactors.py @@ -5,10 +5,10 @@ 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 - ISSUE_TEMPLATE: typing.Final = ( "🚀 New issue to {repository} by @{user}
" "📝 {title} (#{id})

" @@ -41,7 +41,7 @@ def __init__( 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) @@ -54,8 +54,19 @@ def handler(self) -> None: base_url="https://github.com", ) + for e in render_result.entities: + e.pop("language", None) + + tg_body=TgPayload( + text=render_result.text, + entities=render_result.entities, + disable_web_page_preview=True, + chat_id=chat_id, + message_thread_id=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) @@ -90,7 +101,7 @@ def __init__( 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) @@ -103,8 +114,19 @@ def handler(self) -> None: base_url="https://github.com", ) + for e in render_result.entities: + e.pop("language", None) + + tg_body = TgPayload( + text=render_result.text, + entities=render_result.entities, + disable_web_page_preview=True, + chat_id=chat_id, + message_thread_id=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..9b7fcdc 100644 --- a/notifier/application/interfaces.py +++ b/notifier/application/interfaces.py @@ -1,9 +1,8 @@ import abc import typing -import sulguk - from notifier.domain.entities import PullRequest, Issue +from notifier.infrastructure.telegram_gateway import TgPayload class Github(typing.Protocol): @@ -16,4 +15,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) -> typing.Any: ... diff --git a/notifier/infrastructure/github_gateway.py b/notifier/infrastructure/github_gateway.py index 06db82c..d3a0db8 100644 --- a/notifier/infrastructure/github_gateway.py +++ b/notifier/infrastructure/github_gateway.py @@ -1,57 +1,89 @@ -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 -class GithubGateway(interfaces.Github): - def __init__(self, token: str, event_url: str) -> None: +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, + map={ + "id": "number", + "url": "html_url", + "user": ["user", "login"], + "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 = [ + 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", + }, + ), + loader(P[PullRequest], body_pre_loader, Chain.FIRST), + loader(P[PullRequest].labels, lambda labels: [label["name"] for label in labels]), +] + +gh_recipe = [ + *issue_recipe, + *pr_recipe, + +] + +rest = RestBuilder( + request_body_dumper=Retort(), + response_body_loader=Retort(recipe=gh_recipe), + query_param_dumper=Retort(), +) + + +class GithubGateway(RequestsClient, interfaces.Github): + def __init__( + self, + token: str, + event_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(), + 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}"), + ), ) - 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"], - ) + @rest.get("{self._event_url}") + def get_issue(self) -> Issue: # type: ignore[empty-body] + pass + + @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 95bab39..2b2fe1e 100644 --- a/notifier/infrastructure/telegram_gateway.py +++ b/notifier/infrastructure/telegram_gateway.py @@ -1,53 +1,45 @@ -import sys -import time - -import requests +from dataclasses import dataclass +from typing import Any + +from adaptix import Retort +from requests import Session +from requests.adapters import HTTPAdapter +from descanso import RestBuilder +from descanso.http.requests import RequestsClient import sulguk from notifier.application import interfaces +rest = RestBuilder( + request_body_dumper=Retort(), + response_body_loader=Retort(), + query_param_dumper=Retort(), +) + + +@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): -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: # type: ignore[empty-body] + 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