├── notifier ├── __init__.py ├── domain │ ├── __init__.py │ └── entities.py ├── application │ ├── __init__.py │ ├── interfaces.py │ ├── services.py │ └── interactors.py ├── infrastructure │ ├── __init__.py │ ├── telegram_gateway.py │ └── github_gateway.py └── __main__.py ├── .gitignore ├── .static ├── easyp.png ├── reagento.png ├── faststream.png └── wemake-services.png ├── mypy.ini ├── .github ├── zizmor.yml └── workflows │ ├── codeql.yml │ ├── new-event.yml │ ├── master.yml │ └── linters.yaml ├── requirements.txt ├── requirements-dev.txt ├── ruff.toml ├── LICENSE ├── action.yml ├── README.md └── CHANGELOG.md /notifier/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /notifier/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /notifier/application/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /notifier/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | **/__pycache__ 3 | .idea/ -------------------------------------------------------------------------------- /.static/easyp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reagento/relator/HEAD/.static/easyp.png -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | ignore_missing_imports = True 4 | show_error_codes = True 5 | -------------------------------------------------------------------------------- /.static/reagento.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reagento/relator/HEAD/.static/reagento.png -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | dangerous-triggers: 3 | ignore: 4 | - new-event.yml 5 | -------------------------------------------------------------------------------- /.static/faststream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reagento/relator/HEAD/.static/faststream.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sulguk==0.10.1 2 | requests==2.32.5 3 | beautifulsoup4==4.14.2 4 | lxml==6.0.2 5 | -------------------------------------------------------------------------------- /.static/wemake-services.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reagento/relator/HEAD/.static/wemake-services.png -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | ruff>=0.14.0 3 | mypy>=1.18.2 4 | basedpyright >= 1.31.7 5 | types-requests >= 2.32.4.20250913 6 | zizmor >= 1.15.2, <1.16 7 | -------------------------------------------------------------------------------- /notifier/application/interfaces.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import typing 3 | 4 | import sulguk 5 | 6 | from notifier.domain.entities import PullRequest, Issue 7 | 8 | 9 | class Github(typing.Protocol): 10 | @abc.abstractmethod 11 | def get_issue(self) -> Issue: ... 12 | 13 | @abc.abstractmethod 14 | def get_pull_request(self) -> PullRequest: ... 15 | 16 | 17 | class Telegram(typing.Protocol): 18 | @abc.abstractmethod 19 | def send_message(self, render_result: sulguk.RenderResult) -> None: ... 20 | -------------------------------------------------------------------------------- /notifier/domain/entities.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | 4 | @dataclasses.dataclass(kw_only=True) 5 | class Issue: 6 | id: int 7 | title: str 8 | labels: list[str] 9 | url: str 10 | user: str 11 | body: str 12 | 13 | @property 14 | def repository(self) -> str: 15 | return f"{self.url.split('/')[3]}/{self.url.split('/')[4]}" 16 | 17 | 18 | @dataclasses.dataclass(kw_only=True) 19 | class PullRequest: 20 | id: int 21 | title: str 22 | labels: list[str] 23 | url: str 24 | user: str 25 | body: str 26 | additions: int 27 | deletions: int 28 | head_ref: str 29 | base_ref: str 30 | repository: str 31 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | analyze: 9 | name: Analyze 10 | runs-on: ubuntu-latest 11 | permissions: 12 | security-events: write 13 | actions: read 14 | contents: read 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 19 | with: 20 | persist-credentials: false 21 | 22 | - name: Initialize CodeQL 23 | uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee 24 | with: 25 | languages: python 26 | 27 | - name: Perform CodeQL Analysis 28 | uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee 29 | -------------------------------------------------------------------------------- /.github/workflows/new-event.yml: -------------------------------------------------------------------------------- 1 | name: Event Notifier 2 | 3 | on: 4 | issues: 5 | types: [opened, reopened] 6 | pull_request_target: 7 | types: [opened, reopened] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }} 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | issues: read 15 | pull-requests: read 16 | 17 | jobs: 18 | notify: 19 | name: "Telegram notification" 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Send Telegram notification for new issue or pull request 23 | uses: reagento/relator@983edccef69ef9a25b97552daaeaf0f183b470f4 # v1.5.0 24 | with: 25 | tg-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} 26 | tg-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- / ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 79 2 | target-version = "py310" 3 | include = ["notifier/**.py", "main.py"] 4 | 5 | lint.select = [ 6 | "ALL" 7 | ] 8 | lint.ignore = [ 9 | "ARG", 10 | "ANN", 11 | "D", 12 | "EM101", 13 | "EM102", 14 | "PT001", 15 | "PT023", 16 | "SIM108", 17 | "RET505", 18 | "PLR0913", 19 | "SIM103", 20 | "ISC003", 21 | 22 | # identical by code != identical by meaning 23 | "SIM114", 24 | 25 | # awful things, never use. 26 | # It makes runtime work differently from typechecker 27 | "TC001", 28 | "TC002", 29 | "TC003", 30 | "TC006", 31 | ] 32 | 33 | extend-exclude = [ 34 | "venv", 35 | ".venv", 36 | "build", 37 | "dist", 38 | "*.egg-info", 39 | ] 40 | 41 | 42 | [lint.isort] 43 | no-lines-before = ["local-folder"] 44 | 45 | [lint.flake8-tidy-imports] 46 | ban-relative-imports = "parents" 47 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | version: 8 | name: "Release" 9 | permissions: 10 | contents: write 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 14 | with: 15 | ref: master 16 | fetch-depth: 0 17 | persist-credentials: false 18 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 19 | with: 20 | python-version: "3.13" 21 | - name: Install python-semantic-release 22 | run: pip install python-semantic-release 23 | - name: Generate version 24 | env: 25 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | id: set-version 27 | run: | 28 | export GIT_COMMIT_AUTHOR="GitHub Actions " 29 | semantic-release version 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sehat1137 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /notifier/infrastructure/telegram_gateway.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | import requests 5 | import sulguk 6 | 7 | from notifier.application import interfaces 8 | 9 | 10 | class TelegramGateway(interfaces.Telegram): 11 | def __init__( 12 | self, 13 | chat_id: str, 14 | bot_token: str, 15 | attempt_count: int, 16 | message_thread_id: str | int | None, 17 | ) -> None: 18 | self._chat_id = chat_id 19 | self._bot_token = bot_token 20 | self._attempt_count = attempt_count 21 | self._message_thread_id = message_thread_id 22 | 23 | def send_message(self, render_result: sulguk.RenderResult) -> None: 24 | count = 0 25 | payload = self._create_payload(render_result) 26 | url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage" 27 | while count < self._attempt_count: 28 | response = requests.post(url, json=payload, timeout=30) 29 | try: 30 | response.raise_for_status() 31 | except requests.exceptions.HTTPError: 32 | print(response.content, file=sys.stderr) 33 | count += 1 34 | time.sleep(count * 2) 35 | else: 36 | print(response.json(), file=sys.stdout) 37 | return 38 | 39 | def _create_payload(self, render_result: sulguk.RenderResult) -> dict: 40 | for e in render_result.entities: 41 | e.pop("language", None) 42 | 43 | payload = { 44 | "text": render_result.text, 45 | "entities": render_result.entities, 46 | "disable_web_page_preview": True, 47 | } 48 | payload["chat_id"] = self._chat_id 49 | 50 | if self._message_thread_id is not None: 51 | payload["message_thread_id"] = self._message_thread_id 52 | 53 | return payload 54 | -------------------------------------------------------------------------------- /.github/workflows/linters.yaml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | pull_request: 5 | branches: ["master"] 6 | 7 | permissions: 8 | actions: read 9 | contents: read 10 | pull-requests: read 11 | 12 | jobs: 13 | ruff: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 18 | with: 19 | persist-credentials: false 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 23 | with: 24 | python-version: "3.10" 25 | 26 | - name: Install dependencies 27 | run: pip install -r requirements-dev.txt 28 | 29 | - name: Run Ruff 30 | run: ruff check . 31 | mypy: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout code 35 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 36 | with: 37 | persist-credentials: false 38 | 39 | - name: Set up Python 40 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 41 | with: 42 | python-version: "3.10" 43 | 44 | - name: Install dependencies 45 | run: pip install -r requirements-dev.txt 46 | 47 | - name: Run mypy (type checking) 48 | run: mypy . 49 | zizmor: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Checkout code 53 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 54 | with: 55 | persist-credentials: false 56 | 57 | - name: Set up Python 58 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 59 | with: 60 | python-version: "3.10" 61 | 62 | - name: Install dependencies 63 | run: pip install -r requirements-dev.txt 64 | 65 | - name: Run zizmor 66 | run: zizmor .github 67 | -------------------------------------------------------------------------------- /notifier/application/services.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import re 3 | import sys 4 | 5 | import bs4 6 | import sulguk 7 | 8 | 9 | @dataclasses.dataclass(frozen=True, kw_only=True) 10 | class RenderService: 11 | custom_labels: list[str] 12 | join_input_with_list: bool 13 | 14 | def format_body(self, body: str) -> str: 15 | if not body: 16 | return body 17 | 18 | soup = bs4.BeautifulSoup(body, "lxml") 19 | 20 | for s in soup.find_all(class_="blob-wrapper"): 21 | s.extract() 22 | 23 | if self.join_input_with_list: 24 | for ul in soup.find_all("ul"): 25 | if ul.find("input"): 26 | ul.name = "div" 27 | for li in ul.find_all("li"): 28 | li.name = "div" 29 | 30 | result = str(soup) 31 | 32 | try: 33 | sulguk.transform_html(result, base_url="https://github.com") 34 | return result 35 | except Exception as e: 36 | print(f"Error transforming HTML: {e}", file=sys.stderr) 37 | return "

" 38 | 39 | def format_labels(self, labels: list[str]): 40 | return ( 41 | " ".join( 42 | f"#{self._parse_label(label)}" 43 | for label in labels + self.custom_labels 44 | if self._parse_label(label) 45 | ) 46 | + "
" 47 | ) 48 | 49 | def _parse_label(self, raw_label: str) -> str: 50 | """ 51 | Bug Report -> bug_report 52 | high-priority -> high_priority 53 | Feature Request!!! -> feature_request 54 | Version 2.0 -> version_20 55 | Critical Bug - Urgent!!! -> critical_bug___urgent 56 | Багрепорт - ... 57 | already_normalized -> already_normalized 58 | Test@#$%^&*()Label -> testlabel 59 | ... -> ... 60 | """ 61 | parsed_label = raw_label.lower().replace(" ", "_").replace("-", "_") 62 | return re.sub(r"[^a-zA-Z0-9_]", "", parsed_label) 63 | -------------------------------------------------------------------------------- /notifier/infrastructure/github_gateway.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from notifier.application import interfaces 4 | from notifier.domain.entities import Issue, PullRequest 5 | 6 | 7 | class GithubGateway(interfaces.Github): 8 | def __init__(self, token: str, event_url: str) -> None: 9 | self._token = token 10 | self._url = event_url 11 | 12 | def get_issue(self) -> Issue: 13 | headers = { 14 | "Accept": "application/vnd.github.v3.html+json", 15 | "X-GitHub-Api-Version": "2022-11-28", 16 | "Authorization": f"Bearer {self._token}", 17 | } 18 | 19 | response = requests.get(self._url, headers=headers, timeout=30) 20 | response.raise_for_status() 21 | 22 | data = response.json() 23 | 24 | return Issue( 25 | id=data["number"], 26 | title=data["title"], 27 | labels=[label["name"] for label in data["labels"]], 28 | url=(data["html_url"] or "").strip(), 29 | user=data["user"]["login"], 30 | body=(data.get("body_html", "") or "").strip(), 31 | ) 32 | 33 | def get_pull_request(self) -> PullRequest: 34 | headers = { 35 | "Accept": "application/vnd.github.v3.html+json", 36 | "X-GitHub-Api-Version": "2022-11-28", 37 | "Authorization": f"Bearer {self._token}", 38 | } 39 | 40 | response = requests.get(self._url, headers=headers, timeout=30) 41 | response.raise_for_status() 42 | 43 | data = response.json() 44 | 45 | return PullRequest( 46 | id=data["number"], 47 | title=data["title"], 48 | labels=[label["name"] for label in data["labels"]], 49 | url=data["html_url"], 50 | user=data["user"]["login"], 51 | body=(data.get("body_html", "") or "").strip(), 52 | additions=data["additions"], 53 | deletions=data["deletions"], 54 | head_ref=data["head"]["label"], 55 | base_ref=data["base"]["ref"], 56 | repository=data["base"]["repo"]["full_name"], 57 | ) 58 | -------------------------------------------------------------------------------- /notifier/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | 5 | from notifier.application.interactors import SendIssue, SendPR 6 | from notifier.application.services import RenderService 7 | from notifier.infrastructure.github_gateway import GithubGateway 8 | from notifier.infrastructure.telegram_gateway import TelegramGateway 9 | 10 | 11 | def get_interactor(url: str) -> type[SendIssue] | type[SendPR]: 12 | issue_pattern = ( 13 | r"https://(?:api\.)?github\.com/repos/[\w\-\.]+/[\w\-\.]+/issues/\d+" 14 | ) 15 | 16 | pr_pattern = r"https://(?:api\.)?github\.com/repos/[\w\-\.]+/[\w\-\.]+/pulls/\d+" 17 | 18 | if re.match(issue_pattern, url): 19 | return SendIssue 20 | elif re.match(pr_pattern, url): 21 | return SendPR 22 | else: 23 | raise ValueError(f"Unknown event type for URL: {url}") 24 | 25 | 26 | if __name__ == "__main__": 27 | html_template = os.environ.get("HTML_TEMPLATE", "").strip() 28 | 29 | telegram_gateway = TelegramGateway( 30 | chat_id=os.environ["TELEGRAM_CHAT_ID"], 31 | bot_token=os.environ["TELEGRAM_BOT_TOKEN"], 32 | attempt_count=int(os.environ["ATTEMPT_COUNT"]), 33 | message_thread_id=os.environ.get("TELEGRAM_MESSAGE_THREAD_ID"), 34 | ) 35 | 36 | event_url = os.environ["EVENT_URL"] 37 | 38 | github_gateway = GithubGateway( 39 | token=(os.environ.get("GITHUB_TOKEN") or "").strip(), 40 | event_url=event_url, 41 | ) 42 | 43 | custom_labels = os.environ.get("CUSTOM_LABELS", "").split(",") 44 | if custom_labels == [""]: 45 | custom_labels = [] 46 | 47 | render_service = RenderService( 48 | custom_labels=custom_labels, 49 | join_input_with_list=os.environ.get("JOIN_INPUT_WITH_LIST") == "1", 50 | ) 51 | 52 | interactor = get_interactor(event_url)( 53 | template=html_template, 54 | github=github_gateway, 55 | telegram=telegram_gateway, 56 | render_service=render_service, 57 | ) 58 | 59 | try: 60 | interactor.handler() 61 | except Exception as e: 62 | print(f"Error processing event: {e}", file=sys.stderr) 63 | sys.exit(1) 64 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "reagento/relator" 2 | description: "Send Telegram notifications for new GitHub issues or PRs" 3 | author: "Sehat1137" 4 | 5 | inputs: 6 | tg-bot-token: 7 | description: "Telegram Bot Token" 8 | required: true 9 | tg-chat-id: 10 | description: "Telegram Chat ID" 11 | required: true 12 | tg-message-thread-id: 13 | description: "Telegram Message Thread ID" 14 | required: false 15 | github-token: 16 | description: "GitHub Token for API access" 17 | required: false 18 | base-url: 19 | description: "Base URL for sulguk" 20 | required: false 21 | default: "https://github.com" 22 | python-version: 23 | description: "Python version for action" 24 | required: false 25 | default: "3.10" 26 | attempt-count: 27 | description: "Telegram API attempt count" 28 | required: false 29 | default: "2" 30 | html-template: 31 | description: "HTML template for Telegram message" 32 | required: false 33 | join-input-with-list: 34 | description: "Join input with bulleted list for rendering like github" 35 | required: true 36 | default: "0" 37 | custom-labels: 38 | description: "Custom labels to add to every notification (comma-separated)" 39 | required: false 40 | 41 | runs: 42 | using: "composite" 43 | steps: 44 | - name: Set up Python 45 | uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1 46 | with: 47 | python-version: ${{ inputs.python-version }} 48 | 49 | - name: Install dependencies 50 | shell: bash 51 | run: | 52 | pip install -r $GITHUB_ACTION_PATH/requirements.txt 53 | 54 | - name: Send Telegram notification 55 | shell: bash 56 | env: 57 | TELEGRAM_BOT_TOKEN: ${{ inputs.tg-bot-token }} 58 | TELEGRAM_CHAT_ID: ${{ inputs.tg-chat-id }} 59 | GITHUB_TOKEN: ${{ inputs.github-token }} 60 | EVENT_URL: ${{ github.event.issue.url || github.event.pull_request.url }} 61 | BASE_URL: ${{ inputs.base-url }} 62 | ATTEMPT_COUNT: ${{ inputs.attempt-count }} 63 | HTML_TEMPLATE: ${{ inputs.html-template }} 64 | MD_TEMPLATE: ${{ inputs.md-template }} 65 | TELEGRAM_MESSAGE_THREAD_ID: ${{ inputs.tg-message-thread-id }} 66 | JOIN_INPUT_WITH_LIST: ${{ inputs.join-input-with-list }} 67 | CUSTOM_LABELS: ${{ inputs.custom-labels }} 68 | run: | 69 | cd $GITHUB_ACTION_PATH && python3 -m notifier 70 | 71 | branding: 72 | icon: "message-circle" 73 | color: "blue" 74 | -------------------------------------------------------------------------------- /notifier/application/interactors.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import sulguk 4 | 5 | from notifier.application import interfaces 6 | from notifier.application.services import RenderService 7 | from notifier.domain.entities import Issue, PullRequest 8 | 9 | TG_MESSAGE_LIMIT: typing.Final = 4096 10 | 11 | 12 | ISSUE_TEMPLATE: typing.Final = ( 13 | "🚀 New issue to {repository} by @{user}
" 14 | "📝 {title} (#{id})

" 15 | "{body}
" 16 | "{labels}" 17 | "{promo}" 18 | ) 19 | 20 | PR_TEMPLATE: typing.Final = ( 21 | "🎉 New Pull Request to {repository} by @{user}
" 22 | "✨ {title} (#{id})
" 23 | "📊 +{additions}/-{deletions}
" 24 | "🌿 {head_ref} → {base_ref}

" 25 | "{body}
" 26 | "{labels}" 27 | "{promo}" 28 | ) 29 | 30 | 31 | class SendIssue: 32 | def __init__( 33 | self, 34 | template: str, 35 | github: interfaces.Github, 36 | telegram: interfaces.Telegram, 37 | render_service: RenderService, 38 | ) -> None: 39 | self._template = template or ISSUE_TEMPLATE 40 | self._github = github 41 | self._telegram = telegram 42 | self._render_service = render_service 43 | 44 | def handler(self) -> None: 45 | issue = self._github.get_issue() 46 | 47 | labels = self._render_service.format_labels(issue.labels) 48 | body = self._render_service.format_body(issue.body) 49 | 50 | message = self._create_message(issue, body, labels) 51 | 52 | render_result = sulguk.transform_html( 53 | message, 54 | base_url="https://github.com", 55 | ) 56 | 57 | if len(render_result.text) <= TG_MESSAGE_LIMIT: 58 | return self._telegram.send_message(render_result) 59 | 60 | message_without_description = self._create_message(issue, "

", labels) 61 | 62 | sulguk.transform_html( 63 | message_without_description, 64 | base_url="https://github.com", 65 | ) 66 | 67 | def _create_message(self, issue: Issue, body: str, labels: str) -> str: 68 | return self._template.format( 69 | id=issue.id, 70 | user=issue.user, 71 | title=issue.title, 72 | labels=labels, 73 | url=issue.url, 74 | body=body, 75 | repository=issue.repository, 76 | promo="sent via relator", 77 | ) 78 | 79 | 80 | class SendPR: 81 | def __init__( 82 | self, 83 | template: str, 84 | github: interfaces.Github, 85 | telegram: interfaces.Telegram, 86 | render_service: RenderService, 87 | ) -> None: 88 | self._template = template or PR_TEMPLATE 89 | self._github = github 90 | self._telegram = telegram 91 | self._render_service = render_service 92 | 93 | def handler(self) -> None: 94 | pr = self._github.get_pull_request() 95 | 96 | labels = self._render_service.format_labels(pr.labels) 97 | body = self._render_service.format_body(pr.body) 98 | 99 | message = self._create_message(pr, body, labels) 100 | 101 | render_result = sulguk.transform_html( 102 | message, 103 | base_url="https://github.com", 104 | ) 105 | 106 | if len(render_result.text) <= TG_MESSAGE_LIMIT: 107 | return self._telegram.send_message(render_result) 108 | 109 | message_without_description = self._create_message(pr, "

", labels) 110 | 111 | sulguk.transform_html( 112 | message_without_description, 113 | base_url="https://github.com", 114 | ) 115 | 116 | def _create_message(self, pr: PullRequest, body: str, labels: str) -> str: 117 | return self._template.format( 118 | id=pr.id, 119 | user=pr.user, 120 | title=pr.title, 121 | labels=labels, 122 | url=pr.url, 123 | body=body, 124 | repository=pr.repository, 125 | additions=pr.additions, 126 | deletions=pr.deletions, 127 | head_ref=pr.head_ref, 128 | base_ref=pr.base_ref, 129 | promo="sent via relator", 130 | ) 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Relator 🔔 2 | 3 | ![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-success?style=flat&logo=githubactions) 4 | ![Python](https://img.shields.io/badge/Python-3.10%2B-blue?style=flat&logo=python) 5 | ![Telegram](https://img.shields.io/badge/Telegram-Bot-blue?style=flat&logo=telegram) 6 | [![CodeQL](https://github.com/reagento/relator/actions/workflows/codeql.yml/badge.svg)](https://github.com/reagento/relator/actions/workflows/codeql.yml) 7 | 8 | **Relator** (Latin _referre_ - "to report") - delivers beautifully formatted GitHub notifications to Telegram. Get instant alerts for issues and PRs with smart labeling and clean formatting, keeping your team informed in real-time. 9 | 10 | ## ✨ Features 11 | 12 | - **Instant Notifications**: Get real-time alerts for new events 13 | - **Rich Formatting**: Clean HTML and MD formatting 14 | - **Label Support**: Automatically converts GitHub labels to Telegram hashtags 15 | - **Customizable**: Multiple configuration options for different needs 16 | - **Reliable**: Built-in retry mechanism for Telegram API 17 | 18 | ## 🚀 Quick Start 19 | 20 | ### Basic Usage 21 | 22 | ```yaml 23 | name: Event Notifier 24 | 25 | on: 26 | issues: 27 | types: [opened, reopened] 28 | pull_request_target: 29 | types: [opened, reopened] 30 | 31 | permissions: 32 | issues: read 33 | pull_request: read 34 | 35 | jobs: 36 | notify: 37 | name: "Telegram notification" 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Send Telegram notification for new issue or pull request 41 | uses: reagento/relator@v1.6.0 42 | with: 43 | tg-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} 44 | tg-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} 45 | github-token: ${{ secrets.GITHUB_TOKEN }} 46 | ``` 47 | 48 | > github-token it's not required for public projects and is unlikely to hit any [limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users). However, github actions uses IP-based limits, and since github actions has a limited pool of addresses, these limits are considered public, and you'll hit them very quickly. 49 | 50 | ### Advanced Configuration 51 | 52 | ```yaml 53 | - name: Send Telegram notification for new issue 54 | uses: reagento/relator@v1.6.0 55 | with: 56 | tg-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} 57 | tg-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} 58 | github-token: ${{ secrets.GITHUB_TOKEN }} 59 | base-url: "https://github.com/your-org/your-repo" 60 | python-version: "3.10" 61 | attempt-count: "5" 62 | # if you want to join the input with a list of labels 63 | join-input-with-list: "1" 64 | # if you have topics 65 | telegram-message-thread-id: 2 66 | # by default templates exist, these parameters override them 67 | html-template: "New issue by @{user}
{title} (#{id})
{body}{labels}
{promo}" 68 | # Custom tags to add to every notification (comma-separated) 69 | custom-labels: "my_project,custom,etc" 70 | ``` 71 | 72 | ## 🔧 Setup Instructions 73 | 74 | 1. Create a Telegram Bot 75 | 76 | - Message `@BotFather` on [Telegram](https://t.me/botfather) 77 | - Create a new bot with `/newbot` 78 | - Save the bot token 79 | 80 | 2. Get Chat ID 81 | 82 | - Add your bot to the desired chat 83 | - Send a message in the chat 84 | - Visit `https://api.telegram.org/bot/getUpdates` 85 | - Find the chat.id in the response 86 | 87 | 3. Configure GitHub Secrets 88 | Add these secrets in your repository settings: 89 | 90 | - `TELEGRAM_BOT_TOKEN` 91 | - `TELEGRAM_CHAT_ID` 92 | 93 | ## 📋 Example Output 94 | 95 | Your Telegram notifications will look like this: 96 | 97 | Issue: 98 | 99 | ```text 100 | 🚀 New issue by @username 101 | 📌 Bug in authentication module (#123) 102 | 103 | [Issue description content here...] 104 | 105 | #bug #high_priority #authentication 106 | sent via relator 107 | ``` 108 | 109 | Pull requests: 110 | 111 | ```text 112 | 🎉 New Pull Request to test/repo by @username 113 | ✨ Update .gitignore (#3) 114 | 📊 +1/-0 115 | 🌿 Sehat1137:test → master 116 | 117 | [Pull requests description content here...] 118 | 119 | #bug #high_priority #authentication 120 | sent via relator 121 | ``` 122 | 123 | ## 🤝 Acknowledgments 124 | 125 | This action uses the excellent [sulguk](https://github.com/Tishka17/sulguk) library by `@Tishka17` for reliable Telegram message delivery 126 | 127 | ## 🌟 Support 128 | 129 | If you find this action useful, please consider: 130 | 131 | - ⭐ Starring the repository on GitHub 132 | - 🐛 Reporting issues if you find any bugs 133 | - 💡 Suggesting features for future improvements 134 | - 🔄 Sharing with your developer community 135 | 136 | ## 📝 License 137 | 138 | This project is open source and available under the [MIT License](https://opensource.org/licenses/MIT). 139 | 140 | ## ⚙️ Used by 141 | 142 | **Relator** is used by many open source projects here we highlight a few: 143 | 144 | | Project | Logo | Description | 145 | | ------------------------------------------------------------------------------ | -------------------------------------------------- | --------------------------------------------------------- | 146 | | [FastStream](https://github.com/ag2ai/faststream) | | FastStream is a powerful and easy-to-use Python framework | 147 | | [Dishka](https://github.com/reagento/dishka) | | Cute dependency injection (DI) framework for Python | 148 | | [easyp](https://github.com/easyp-tech/easyp) | | Easyp is a cli tool for workflows with proto files | 149 | | [wemake.services](https://github.com/wemake-services/wemake-python-styleguide) | | The strictest and most opinionated python linter ever! | 150 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | 4 | 5 | ## v1.6.0 (2025-11-05) 6 | 7 | ### Chores 8 | 9 | - Refactor codebase to use clean architecture with dedicated modules 10 | ([#15](https://github.com/reagento/relator/pull/15), 11 | [`b6df68a`](https://github.com/reagento/relator/commit/b6df68a7398bd4661918748f0bfeb51a9154e14c)) 12 | 13 | ### Features 14 | 15 | - Support github tables ([#16](https://github.com/reagento/relator/pull/16), 16 | [`8f09fca`](https://github.com/reagento/relator/commit/8f09fca8c4214c892dd1580da3ae10f10e9ab66b)) 17 | 18 | 19 | ## v1.5.1 (2025-10-17) 20 | 21 | ### Bug Fixes 22 | 23 | - Repair render 24 | ([`e981efa`](https://github.com/reagento/relator/commit/e981efa245d733b5ee5733e9cd709c2f14d8de28)) 25 | 26 | ### Chores 27 | 28 | - Add zizmor ([#13](https://github.com/reagento/relator/pull/13), 29 | [`448327a`](https://github.com/reagento/relator/commit/448327a02996af1f36cb70edf8a4d8ada55d7e94)) 30 | 31 | - Change marketplace name 32 | ([`70a29c0`](https://github.com/reagento/relator/commit/70a29c01c116c1de64226b1be15ea8f0daeafc40)) 33 | 34 | - **dev**: Add ruff.toml and mypy.ini, requirements-dev.txt, github action for ruff and mypy 35 | ([#11](https://github.com/reagento/relator/pull/11), 36 | [`ccded9b`](https://github.com/reagento/relator/commit/ccded9bb58a62f7d798c78f96f6fa1c1fd064d6d)) 37 | 38 | ### Continuous Integration 39 | 40 | - Add new workflow 41 | ([`12fa9f7`](https://github.com/reagento/relator/commit/12fa9f7cdc75c2ac330f35309e5088ab5f174984)) 42 | 43 | ### Documentation 44 | 45 | - Extend used by ([#12](https://github.com/reagento/relator/pull/12), 46 | [`be801f0`](https://github.com/reagento/relator/commit/be801f0425e6819aa99b9ae01390337f81df12f6)) 47 | 48 | - Fix typo 49 | ([`a14a60c`](https://github.com/reagento/relator/commit/a14a60cb90ec49b5226c01ca5fb6e3d64b46c490)) 50 | 51 | 52 | ## v1.5.0 (2025-10-13) 53 | 54 | ### Chores 55 | 56 | - Migrate project to reagento 57 | ([`7cd0b0f`](https://github.com/reagento/relator/commit/7cd0b0f266f1339bf3c0351023aacc77740f68f3)) 58 | 59 | - Update sulguk 60 | ([`a825396`](https://github.com/reagento/relator/commit/a825396feb6a6bf111298d29d98ff484cf283548)) 61 | 62 | ### Features 63 | 64 | - Add new render options JOIN_INPUT_WITH_LIST 65 | ([`f8abad8`](https://github.com/reagento/relator/commit/f8abad843a45ea0a067e8c50441fd01d57e19186)) 66 | 67 | - Add repository to issue 68 | ([`66f5d86`](https://github.com/reagento/relator/commit/66f5d865df308a5fb093071b733503f68106214c)) 69 | 70 | 71 | ## v1.4.1 (2025-10-07) 72 | 73 | ### Bug Fixes 74 | 75 | - Add support for input 76 | ([`13a4e47`](https://github.com/Sehat1137/telegram-notifier/commit/13a4e47becc25c5955dc6cfce58cedd15e45969f)) 77 | 78 | - Typo MD template PR 79 | ([`3411866`](https://github.com/Sehat1137/telegram-notifier/commit/34118668d9efc950ec06f9a3e2cd3a1a3e9e2b63)) 80 | 81 | ### Documentation 82 | 83 | - Update readme 84 | ([`fa078c4`](https://github.com/Sehat1137/telegram-notifier/commit/fa078c4641716c8db5cc74a16be2dde25d0cd60d)) 85 | 86 | 87 | ## v1.4.0 (2025-10-07) 88 | 89 | ### Bug Fixes 90 | 91 | - Reduce Telegram API attempt count default to 2 92 | ([`4fee31e`](https://github.com/Sehat1137/telegram-notifier/commit/4fee31e25ccfefd8af064f89e4f0804775e6301c)) 93 | 94 | ### Documentation 95 | 96 | - Update readme 97 | ([`ff6a0b6`](https://github.com/Sehat1137/telegram-notifier/commit/ff6a0b676c5adf883a953652c75a073b15cd1757)) 98 | 99 | ### Features 100 | 101 | - Event support without GITHUB_TOKEN 102 | ([`3ebd7b4`](https://github.com/Sehat1137/telegram-notifier/commit/3ebd7b43587c33fa5c0bc512390602bfda2845ca)) 103 | 104 | 105 | ## v1.3.0 (2025-10-04) 106 | 107 | ### Bug Fixes 108 | 109 | - Improve message length handling in issue formatting 110 | ([`eba01f4`](https://github.com/Sehat1137/telegram-notifier/commit/eba01f4e68a2b6bcd60cd9f704c78d1e1df13e53)) 111 | 112 | - Refactor action into modular components and update templates 113 | ([`bcdc12e`](https://github.com/Sehat1137/telegram-notifier/commit/bcdc12e0fc23ebc7e72d751a31d3e40bd3f1fb7d)) 114 | 115 | ### Continuous Integration 116 | 117 | - Change release rules 118 | ([`f002f6b`](https://github.com/Sehat1137/telegram-notifier/commit/f002f6b14ca21ee5111eef05217e4ebe31120061)) 119 | 120 | ### Features 121 | 122 | - Add new new event PR 123 | ([`d14df7a`](https://github.com/Sehat1137/telegram-notifier/commit/d14df7afc41420ec6c1b6f8630c11b640caedd5d)) 124 | 125 | 126 | ## v1.2.3 (2025-09-05) 127 | 128 | ### Bug Fixes 129 | 130 | - Change message format 131 | ([`956ea49`](https://github.com/Sehat1137/telegram-notifier/commit/956ea49cc81b95f3391de801b9df601b686e6023)) 132 | 133 | ### Documentation 134 | 135 | - Update 136 | ([`2052255`](https://github.com/Sehat1137/telegram-notifier/commit/205225577f72860bfb821ac9b6dcfb7de3ad09c0)) 137 | 138 | 139 | ## v1.2.2 (2025-09-05) 140 | 141 | ### Bug Fixes 142 | 143 | - Support thread 144 | ([`5eed143`](https://github.com/Sehat1137/telegram-notifier/commit/5eed1439c25b49012f5c9e38ce5b78c1793014ed)) 145 | 146 | 147 | ## v1.2.1 (2025-09-05) 148 | 149 | ### Bug Fixes 150 | 151 | - Error if we have empty template 152 | ([`6992f9b`](https://github.com/Sehat1137/telegram-notifier/commit/6992f9bf36a04400a335b1a330992ad8be751da7)) 153 | 154 | ### Documentation 155 | 156 | - Update 157 | ([`01ab008`](https://github.com/Sehat1137/telegram-notifier/commit/01ab008dba34b290b45e7de349c4ea8b43b7e71e)) 158 | 159 | 160 | ## v1.2.0 (2025-09-05) 161 | 162 | ### Features 163 | 164 | - Add topic support 165 | ([`42ac3d6`](https://github.com/Sehat1137/telegram-notifier/commit/42ac3d6e5565de522ba428ca1ca4e2ab7a8af328)) 166 | 167 | 168 | ## v1.1.0 (2025-09-05) 169 | 170 | ### Bug Fixes 171 | 172 | - Apply message to tg limit 173 | ([`8e0e612`](https://github.com/Sehat1137/telegram-notifier/commit/8e0e612940e295f1a2b360829aace57fcf3bbe1b)) 174 | 175 | ### Features 176 | 177 | - Add support for custom templates 178 | ([`98545c5`](https://github.com/Sehat1137/telegram-notifier/commit/98545c5921ff3f45265cddcd9621cfa4596e0946)) 179 | 180 | - Support trigger lables 181 | ([`000d907`](https://github.com/Sehat1137/telegram-notifier/commit/000d9078466c174da129f002167eeb0b3257c092)) 182 | 183 | 184 | ## v1.0.1 (2025-09-04) 185 | 186 | ### Bug Fixes 187 | 188 | - Attempt_count usage 189 | ([`bb5c48f`](https://github.com/Sehat1137/telegram-notifier/commit/bb5c48fe2efeb6a34ab27205cb28e55687f82ee3)) 190 | 191 | ### Continuous Integration 192 | 193 | - Change commit author 194 | ([`48984fc`](https://github.com/Sehat1137/telegram-notifier/commit/48984fce36910771e0656636a1adff00d16da7c7)) 195 | 196 | 197 | ## v1.0.0 (2025-09-01) 198 | 199 | - Initial Release 200 | --------------------------------------------------------------------------------