From 86af13f09ed4b59e487fb7ecef1db2cbd3ed2c4b Mon Sep 17 00:00:00 2001 From: Evgeniy Alekseev Date: Fri, 8 Apr 2022 03:41:07 +0300 Subject: [PATCH] add telegram integraion --- docs/configuration.md | 11 +++ docs/faq.md | 40 ++++++++ package/share/ahriman/settings/ahriman.ini | 3 + .../ahriman/templates/telegram-index.jinja2 | 10 ++ src/ahriman/core/report/report.py | 3 + src/ahriman/core/report/telegram.py | 93 +++++++++++++++++++ src/ahriman/models/report_settings.py | 4 + tests/ahriman/core/report/test_report.py | 9 ++ tests/ahriman/core/report/test_telegram.py | 83 +++++++++++++++++ tests/ahriman/core/test_util.py | 1 + tests/ahriman/models/test_report_settings.py | 3 + tests/testresources/core/ahriman.ini | 7 ++ 12 files changed, 267 insertions(+) create mode 100644 package/share/ahriman/templates/telegram-index.jinja2 create mode 100644 src/ahriman/core/report/telegram.py create mode 100644 tests/ahriman/core/report/test_telegram.py diff --git a/docs/configuration.md b/docs/configuration.md index 92d8a7d7..e5575a9e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -114,6 +114,17 @@ Section name must be either `html` (plus optional architecture name, e.g. `html: * `link_path` - prefix for HTML links, string, required. * `template_path` - path to Jinja2 template, string, required. +### `telegram` type + +Section name must be either `telegram` (plus optional architecture name, e.g. `telegram:x86_64`) or random name with `type` set. + +* `type` - type of the report, string, optional, must be set to `telegram` if exists. +* `api_key` - telegram bot API key, string, required. Please refer FAQ about how to create chat and bot +* `chat_id` - telegram chat id, either string with `@` or integer value, required. +* `homepage` - link to homepage, string, optional. +* `link_path` - prefix for HTML links, string, required. +* `template_path` - path to Jinja2 template, string, required. + ## `upload` group Remote synchronization settings. diff --git a/docs/faq.md b/docs/faq.md index 6181f46f..3a369163 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -402,6 +402,46 @@ There are several choices: After these steps `index.html` file will be automatically synced to S3 +### I would like to get messages to my telegram account/channel + +1. It still requires additional dependencies: + + ```shell + yay -S python-jinja + ``` + +2. Register bot in telegram. You can do it by using by talking with [@BotFather](https://t.me/botfather). For more details please refer to [official documentation](https://core.telegram.org/bots). + +3. Optionally (if you want to post message in chat): + + 1. Create telegram channel. + 2. Invite your bot into the channel. + 3. Make your channel public + +4. Get chat id if you want to use by numerical id or just use id prefixed with `@` (e.g. `@ahriman`). If you are not using chat the chat id is your user id. If you don't want to make channel public you can use [this guide](https://stackoverflow.com/a/33862907). + +5. Configure the service: + + ```ini + [report] + target = telegram + + [telegram] + api_key = aaAAbbBBccCC + chat_id = @ahriman + link_path = http://example.com/x86_64 + ``` + + `api_key` is the one sent by [@BotFather](https://t.me/botfather), `chat_id` is the value retrieved from previous step. + +If you did everything fine you should receive the message with the next update. Quick credentials check can be done by using the following command: + +```shell +curl 'https://api.telegram.org/bot${CHAT_ID}/sendMessage?chat_id=${API_KEY}&text=hello' +``` + +(replace `${CHAT_ID}` and `${API_KEY}` with the values from configuration). + ## Web service ### Readme mentions web interface, how do I use it? diff --git a/package/share/ahriman/settings/ahriman.ini b/package/share/ahriman/settings/ahriman.ini index 4a87d835..aa0991d8 100644 --- a/package/share/ahriman/settings/ahriman.ini +++ b/package/share/ahriman/settings/ahriman.ini @@ -45,6 +45,9 @@ ssl = disabled [html] template_path = /usr/share/ahriman/templates/repo-index.jinja2 +[telegram] +template_path = /usr/share/ahriman/templates/telegram.jinja2 + [upload] target = diff --git a/package/share/ahriman/templates/telegram-index.jinja2 b/package/share/ahriman/templates/telegram-index.jinja2 new file mode 100644 index 00000000..5c378745 --- /dev/null +++ b/package/share/ahriman/templates/telegram-index.jinja2 @@ -0,0 +1,10 @@ +{#simplified version of full report, now in markdown#} +## {{ repository }} update + +{% for package in packages %} + * [{{ package.name }}]({{ link_path }}/{{ package.filename }}) {{ package.version }} at {{ package.build_date }} +{% endfor %} + +{% if homepage is not none %} +[Repository]({{ homepage }}) +{% endif %} diff --git a/src/ahriman/core/report/report.py b/src/ahriman/core/report/report.py index 6d0bdd0e..0298c9f1 100644 --- a/src/ahriman/core/report/report.py +++ b/src/ahriman/core/report/report.py @@ -68,6 +68,9 @@ class Report: if provider == ReportSettings.Console: from ahriman.core.report.console import Console return Console(architecture, configuration, section) + if provider == ReportSettings.Telegram: + from ahriman.core.report.telegram import Telegram + return Telegram(architecture, configuration, section) return cls(architecture, configuration) # should never happen def generate(self, packages: Iterable[Package], result: Result) -> None: diff --git a/src/ahriman/core/report/telegram.py b/src/ahriman/core/report/telegram.py new file mode 100644 index 00000000..5db71ae3 --- /dev/null +++ b/src/ahriman/core/report/telegram.py @@ -0,0 +1,93 @@ +# +# Copyright (c) 2021-2022 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# technically we could use python-telegram-bot, but it is just a single request, cmon +import requests + +from typing import Iterable + +from ahriman.core.configuration import Configuration +from ahriman.core.report.jinja_template import JinjaTemplate +from ahriman.core.report.report import Report +from ahriman.core.util import exception_response_text +from ahriman.models.package import Package +from ahriman.models.result import Result + + +class Telegram(Report, JinjaTemplate): + """ + telegram report generator + :cvar TELEGRAM_API_URL: telegram api base url + :cvar TELEGRAM_MAX_CONTENT_LENGTH: max content length of the message + :ivar api_key: bot api key + :ivar chat_id: chat id to post message, either string with @ or integer + :ivar template_path: path to template for built packages + """ + + TELEGRAM_API_URL = "https://api.telegram.org" + TELEGRAM_MAX_CONTENT_LENGTH = 4096 + + def __init__(self, architecture: str, configuration: Configuration, section: str) -> None: + """ + default constructor + :param architecture: repository architecture + :param configuration: configuration instance + :param section: settings section name + """ + Report.__init__(self, architecture, configuration) + JinjaTemplate.__init__(self, section, configuration) + + self.api_key = configuration.get(section, "api_key") + self.chat_id = configuration.get(section, "chat_id") + self.template_path = configuration.getpath(section, "template_path") + + def _send(self, text: str) -> None: + """ + send message to telegram channel + :param text: message body text + """ + try: + response = requests.post( + f"{self.TELEGRAM_API_URL}/bot{self.api_key}/sendMessage", + data={"chat_id": self.chat_id, "text": text}) + response.raise_for_status() + except requests.HTTPError as e: + self.logger.exception("could not perform request: %s", exception_response_text(e)) + raise + except Exception: + self.logger.exception("could not perform request") + raise + + def generate(self, packages: Iterable[Package], result: Result) -> None: + """ + generate report for the specified packages + :param packages: list of packages to generate report + :param result: build result + """ + if not result.success: + return + text = self.make_html(result, self.template_path) + # telegram content is limited by 4096 symbols, so we are going to split the message by new lines + # to fit into this restriction + if len(text) > self.TELEGRAM_MAX_CONTENT_LENGTH: + position = text.rfind("\n", 0, self.TELEGRAM_MAX_CONTENT_LENGTH) + portion, text = text[:position], text[position + 1:] # +1 to exclude newline we split + self._send(portion) + # send remaining (or full in case if size is less than max length) text + self._send(text) diff --git a/src/ahriman/models/report_settings.py b/src/ahriman/models/report_settings.py index a52dc63e..1047c377 100644 --- a/src/ahriman/models/report_settings.py +++ b/src/ahriman/models/report_settings.py @@ -32,12 +32,14 @@ class ReportSettings(Enum): :cvar HTML: html report generation :cvar Email: email report generation :cvar Console: print result to console + :cvar Telegram: markdown report to telegram channel """ Disabled = "disabled" # for testing purpose HTML = "html" Email = "email" Console = "console" + Telegram = "telegram" @classmethod def from_option(cls: Type[ReportSettings], value: str) -> ReportSettings: @@ -52,4 +54,6 @@ class ReportSettings(Enum): return cls.Email if value.lower() in ("console",): return cls.Console + if value.lower() in ("telegram",): + return cls.Telegram raise InvalidOption(value) diff --git a/tests/ahriman/core/report/test_report.py b/tests/ahriman/core/report/test_report.py index 623dd0cd..58099264 100644 --- a/tests/ahriman/core/report/test_report.py +++ b/tests/ahriman/core/report/test_report.py @@ -53,3 +53,12 @@ def test_report_html(configuration: Configuration, result: Result, mocker: Mocke report_mock = mocker.patch("ahriman.core.report.html.HTML.generate") Report.load("x86_64", configuration, "html").run([], result) report_mock.assert_called_once_with([], result) + + +def test_report_telegram(configuration: Configuration, result: Result, mocker: MockerFixture) -> None: + """ + must generate telegram report + """ + report_mock = mocker.patch("ahriman.core.report.telegram.Telegram.generate") + Report.load("x86_64", configuration, "telegram").run([], result) + report_mock.assert_called_once_with([], result) diff --git a/tests/ahriman/core/report/test_telegram.py b/tests/ahriman/core/report/test_telegram.py new file mode 100644 index 00000000..1e0c8259 --- /dev/null +++ b/tests/ahriman/core/report/test_telegram.py @@ -0,0 +1,83 @@ +import pytest +import requests + +from pytest_mock import MockerFixture +from unittest import mock + +from ahriman.core.configuration import Configuration +from ahriman.core.report.telegram import Telegram +from ahriman.models.package import Package +from ahriman.models.result import Result + + +def test_send(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must send a message + """ + request_mock = mocker.patch("requests.post") + report = Telegram("x86_64", configuration, "telegram") + + report._send("a text") + request_mock.assert_called_once_with( + pytest.helpers.anyvar(str, strict=True), + data={"chat_id": pytest.helpers.anyvar(str, strict=True), "text": "a text"}) + + +def test_send_failed(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must reraise generic exception + """ + mocker.patch("requests.post", side_effect=Exception()) + report = Telegram("x86_64", configuration, "telegram") + + with pytest.raises(Exception): + report._send("a text") + + +def test_make_request_failed_http_error(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must reraise http exception + """ + mocker.patch("requests.post", side_effect=requests.exceptions.HTTPError()) + report = Telegram("x86_64", configuration, "telegram") + + with pytest.raises(requests.exceptions.HTTPError): + report._send("a text") + + +def test_generate(configuration: Configuration, package_ahriman: Package, result: Result, + mocker: MockerFixture) -> None: + """ + must generate report + """ + send_mock = mocker.patch("ahriman.core.report.telegram.Telegram._send") + + report = Telegram("x86_64", configuration, "telegram") + report.generate([package_ahriman], result) + send_mock.assert_called_once_with(pytest.helpers.anyvar(int)) + + +def test_generate_big_text(configuration: Configuration, package_ahriman: Package, result: Result, + mocker: MockerFixture) -> None: + """ + must generate report with big text + """ + mocker.patch("ahriman.core.report.jinja_template.JinjaTemplate.make_html", return_value="a\n" * 4096) + send_mock = mocker.patch("ahriman.core.report.telegram.Telegram._send") + + report = Telegram("x86_64", configuration, "telegram") + report.generate([package_ahriman], result) + send_mock.assert_has_calls([ + mock.call(pytest.helpers.anyvar(str, strict=True)), mock.call(pytest.helpers.anyvar(str, strict=True)) + ]) + + +def test_generate_no_empty(configuration: Configuration, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must generate report + """ + send_mock = mocker.patch("ahriman.core.report.telegram.Telegram._send") + + report = Telegram("x86_64", configuration, "telegram") + report.generate([package_ahriman], Result()) + send_mock.assert_not_called() diff --git a/tests/ahriman/core/test_util.py b/tests/ahriman/core/test_util.py index 3397bb1f..9cebe1bc 100644 --- a/tests/ahriman/core/test_util.py +++ b/tests/ahriman/core/test_util.py @@ -329,6 +329,7 @@ def test_walk(resource_path_root: Path) -> None: resource_path_root / "web" / "templates" / "build-status.jinja2", resource_path_root / "web" / "templates" / "email-index.jinja2", resource_path_root / "web" / "templates" / "repo-index.jinja2", + resource_path_root / "web" / "templates" / "telegram-index.jinja2", ]) local_files = list(sorted(walk(resource_path_root))) assert local_files == expected diff --git a/tests/ahriman/models/test_report_settings.py b/tests/ahriman/models/test_report_settings.py index 86f489d4..3290866c 100644 --- a/tests/ahriman/models/test_report_settings.py +++ b/tests/ahriman/models/test_report_settings.py @@ -24,3 +24,6 @@ def test_from_option_valid() -> None: assert ReportSettings.from_option("console") == ReportSettings.Console assert ReportSettings.from_option("conSOle") == ReportSettings.Console + + assert ReportSettings.from_option("telegram") == ReportSettings.Telegram + assert ReportSettings.from_option("TElegraM") == ReportSettings.Telegram diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini index 60f304c1..9fdda429 100644 --- a/tests/testresources/core/ahriman.ini +++ b/tests/testresources/core/ahriman.ini @@ -52,6 +52,13 @@ homepage = link_path = template_path = ../web/templates/repo-index.jinja2 +[telegram] +api_key = apikey +chat_id = @ahrimantestchat +homepage = +link_path = +template_path = ../web/templates/telegram-index.jinja2 + [upload] target =