diff --git a/poetry.lock b/poetry.lock index 882dd2b..5bc8d84 100644 --- a/poetry.lock +++ b/poetry.lock @@ -151,6 +151,24 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib- tests = ["attrs[tests-no-zope]", "zope-interface"] tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +[[package]] +name = "beautifulsoup4" +version = "4.12.2" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a"}, + {file = "beautifulsoup4-4.12.2.tar.gz", hash = "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "black" version = "23.3.0" @@ -596,13 +614,13 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- [[package]] name = "pluggy" -version = "1.0.0" +version = "1.1.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.1.0-py3-none-any.whl", hash = "sha256:d81d19a3a88d82ed06998353ce5d5c02587ef07ee2d808ae63904ab0ccef0087"}, + {file = "pluggy-1.1.0.tar.gz", hash = "sha256:c500b592c5512df35622e4faf2135aa0b7e989c7d31344194b4afb9d5e47b1bf"}, ] [package.extras] @@ -677,6 +695,17 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-g testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "soupsieve" +version = "2.4.1" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "soupsieve-2.4.1-py3-none-any.whl", hash = "sha256:1c1bfee6819544a3447586c889157365a27e10d88cde3ad3da0cf0ddf646feb8"}, + {file = "soupsieve-2.4.1.tar.gz", hash = "sha256:89d12b2d5dfcd2c9e8c22326da9d9aa9cb3dfab0a83a024f05704076ee8d35ea"}, +] + [[package]] name = "yarl" version = "1.9.2" @@ -767,4 +796,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "34afc83c805a39863cc1a214127e30c071d94387ab1efcb398df3c3d67699e46" +content-hash = "0cce046078dad0fc1b3edabda140722614f0e88531f5255581d5de21227f7b6d" diff --git a/pyproject.toml b/pyproject.toml index 871e087..3ed4be9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ python = "^3.11" "discord.py" = "^2.3" aiohttp = "^3.8" python-dotenv = "^1.0" +beautifulsoup4 = "^4.12" [tool.poetry.dev-dependencies] black = "^23.3" diff --git a/wulkabot/bot.py b/wulkabot/bot.py index 3cdb8b4..699c896 100644 --- a/wulkabot/bot.py +++ b/wulkabot/bot.py @@ -15,6 +15,7 @@ class Wulkabot(commands.Bot): help_command=None, intents=discord.Intents(guilds=True, messages=True, message_content=True), allowed_mentions=discord.AllowedMentions.none(), + max_ratelimit_timeout=10, ) async def setup_hook(self) -> None: diff --git a/wulkabot/cogs/github.py b/wulkabot/cogs/github.py index b33c717..79669d2 100644 --- a/wulkabot/cogs/github.py +++ b/wulkabot/cogs/github.py @@ -6,7 +6,7 @@ import discord from discord.ext import commands from .. import bot -from ..utils import github +from ..utils import data_utils, github from ..utils.constants import GITHUB_REPO from ..utils.views import DeleteButton @@ -149,8 +149,7 @@ class GitHub(commands.Cog): if message.author.bot: return - # `dict.fromkeys` allows us to deduplicate the list whilst preserving order - words = dict.fromkeys(message.content.casefold().split()).keys() + words = data_utils.deduplicate_list(message.content.casefold().split()) embeds = [] try: diff --git a/wulkabot/cogs/vulcan_status.py b/wulkabot/cogs/vulcan_status.py new file mode 100644 index 0000000..2bab328 --- /dev/null +++ b/wulkabot/cogs/vulcan_status.py @@ -0,0 +1,66 @@ +import discord +from discord import app_commands +from discord.ext import commands + +from .. import bot +from ..utils import constants, vulcan_status +from ..utils.vulcan_status import Result, Status + + +def status_embed(status: list[tuple[str, Status]]) -> discord.Embed: + ok = [] + errors = [] + + for domain, result in status: + match result.state: + case Result.OK: + ok.append(domain) + continue + case Result.DATABASE_UPDATE: + icon = "🔄" + case Result.BREAK: + icon = "⚠️" + case Result.ERROR: + icon = "‼️" + case Result.TIMEOUT: + icon = "⌛" + case _: + icon = "❓" + + errors.append(f"{icon} {domain}: {result.message or result.status_code}") + + if errors: + error_text = "\n".join(errors) + else: + error_text = "🟢 Wszystko działa!" + + if ok: + ok_text = ", ".join(ok) + else: + ok_text = "🔥 Nic nie działa" + + return ( + discord.Embed(title="Status dziennika", colour=constants.ACCENT_COLOR) + .add_field(name="Błędy", value=error_text, inline=False) + .add_field(name="Działające usługi", value=ok_text, inline=False) + ) + + +class VulcanStatus(commands.Cog): + def __init__(self, bot: bot.Wulkabot) -> None: + super().__init__() + self.bot = bot + + @app_commands.command() + async def status(self, interaction: discord.Interaction): + """Sprawdza status dziennika""" + await interaction.response.defer(thinking=True) + + status = await vulcan_status.check_all(self.bot.http_client) + embed = status_embed(status) + + await interaction.followup.send(embed=embed) + + +async def setup(bot: bot.Wulkabot): + await bot.add_cog(VulcanStatus(bot)) diff --git a/wulkabot/cogs/wulkanowy.py b/wulkabot/cogs/wulkanowy.py index b83c770..406b0c2 100644 --- a/wulkabot/cogs/wulkanowy.py +++ b/wulkabot/cogs/wulkanowy.py @@ -5,9 +5,9 @@ from discord import app_commands from discord.ext import commands from .. import bot -from ..utils import github, wulkanowy_manager +from ..utils import data_utils, github, wulkanowy_manager from ..utils.constants import ACCENT_COLOR, BUILDS_CHANNEL_ID, GITHUB_REPO -from ..utils.wulkanowy_manager import WulkanowyBuild, WulkanowyManagerException +from ..utils.wulkanowy_manager import WulkanowyBuild OTHER_DOWNLOADS = " | ".join( ( @@ -46,7 +46,8 @@ class Wulkanowy(commands.Cog): pulls = await self.github.fetch_open_pulls(*GITHUB_REPO) branches = ["develop"] branches.extend((pull["head"]["ref"] for pull in pulls)) - builds: list[WulkanowyBuild | WulkanowyManagerException] = await asyncio.gather( + branches = data_utils.deduplicate_list(branches) + builds = await asyncio.gather( *map(self.wulkanowy_manager.fetch_branch_build, branches), return_exceptions=True ) lines = "\n".join( diff --git a/wulkabot/utils/data_utils.py b/wulkabot/utils/data_utils.py new file mode 100644 index 0000000..656b26c --- /dev/null +++ b/wulkabot/utils/data_utils.py @@ -0,0 +1,7 @@ +from typing import TypeVar + +_T = TypeVar("_T") + + +def deduplicate_list(data: list[_T]) -> list[_T]: + return list(dict.fromkeys(data).keys()) diff --git a/wulkabot/utils/github.py b/wulkabot/utils/github.py index 4418832..ebb5cfa 100644 --- a/wulkabot/utils/github.py +++ b/wulkabot/utils/github.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Any import aiohttp diff --git a/wulkabot/utils/vulcan_status.py b/wulkabot/utils/vulcan_status.py new file mode 100644 index 0000000..1a1d40b --- /dev/null +++ b/wulkabot/utils/vulcan_status.py @@ -0,0 +1,160 @@ +import asyncio +from enum import Enum + +import aiohttp +from bs4 import BeautifulSoup + + +class Domain: + def __init__(self, name: str, host: str, expected_title: str) -> None: + self.name = name + self.host = host + self.expected_title = expected_title + + +DOMAINS: list[Domain] = [ + Domain("vulcan.net.pl", "https://uonetplus.vulcan.net.pl/warszawa/", "Dziennik UONET+"), + Domain("vulcan.net.pl: Uczeń", "https://uonetplus-uczen.vulcan.net.pl/warszawa", "Uczeń"), + Domain( + "vulcan.net.pl: Wiadomości Plus", + "https://uonetplus-wiadomosciplus.vulcan.net.pl/warszawa", + "Wiadomości Plus", + ), + Domain("vulcan.net.pl: Aplikacja mobilna", "https://lekcjaplus.vulcan.net.pl", "Eduone"), + Domain("umt.tarnow.pl", "https://uonetplus.umt.tarnow.pl/tarnow", "Zaloguj"), + Domain("umt.tarnow.pl: Uczeń", "https://uonetplus-uczen.umt.tarnow.pl/tarnow", "Zaloguj"), + Domain( + "umt.tarnow.pl: Wiadomości Plus", + "https://uonetplus-wiadomosciplus.umt.tarnow.pl/tarnow", + "Zaloguj", + ), + Domain( + "umt.tarnow.pl: Aplikacja mobilna", + "https://uonetplus-komunikacja.umt.tarnow.pl/tarnow", + "UONET+ dla urządzeń mobilnych", + ), + Domain( + "eszkola.opolskie.pl", + "https://uonetplus.eszkola.opolskie.pl/opole", + "Logowanie do systemu Opolska e-Szkola", + ), + Domain( + "eszkola.opolskie.pl: Uczeń", + "https://uonetplus-uczen.eszkola.opolskie.pl/opole", + "Logowanie do systemu Opolska e-Szkola", + ), + Domain( + "eszkola.opolskie.pl: Wiadomości Plus", + "https://uonetplus-wiadomosciplus.eszkola.opolskie.pl/opole", + "Logowanie do systemu Opolska e-Szkola", + ), + Domain( + "eszkola.opolskie.pl: Aplikacja mobilna", + "https://uonetplus-komunikacja.eszkola.opolskie.pl/opole", + "UONET+ dla urządzeń mobilnych", + ), + Domain("Rzeszów", "https://portal.vulcan.net.pl/rzeszowprojekt", "Platforma VULCAN"), + Domain( + "resman.pl: Uczeń", + "https://uonetplus-uczen.vulcan.net.pl/rzeszowprojekt", + "Logowanie do systemu", + ), + Domain( + "Rzeszów: Wiadomości Plus", + "https://uonetplus-wiadomosciplus.vulcan.net.pl/rzeszowprojekt", + "Logowanie do systemu", + ), + Domain("edu.gdansk.pl", "https://uonetplus.edu.gdansk.pl/gdansk", "Logowanie do systemu"), + Domain( + "edu.gdansk.pl: Uczeń", + "https://uonetplus-uczen.edu.gdansk.pl/gdansk", + "Logowanie do systemu", + ), + Domain( + "edu.gdansk.pl: Wiadomości Plus", + "https://uonetplus-wiadomosciplus.edu.gdansk.pl/gdansk", + "Logowanie do systemu", + ), + Domain( + "edu.gdansk.pl: Aplikacja mobilna", + "https://uonetplus-komunikacja.edu.gdansk.pl/gdansk", + "UONET+ dla urządzeń mobilnych", + ), + Domain("edu.lublin.eu", "https://uonetplus.edu.lublin.eu/lublin", "Logowanie do systemu"), + Domain( + "edu.lublin.eu: Uczeń", + "https://uonetplus-uczen.edu.lublin.eu/lublin", + "Logowanie do systemu", + ), + Domain( + "edu.lublin.eu: Wiadomości Plus", + "https://uonetplus-wiadomosciplus.edu.lublin.eu/lublin", + "Logowanie do systemu", + ), + Domain( + "edu.lublin.eu: Aplikacja mobilna", + "https://uonetplus-komunikacja.edu.lublin.eu/lublin", + "UONET+ dla urządzeń mobilnych", + ), + Domain("eduportal.koszalin.pl", "https://uonetplus.eduportal.koszalin.pl/koszalin", "Zaloguj"), + Domain( + "eduportal.koszalin.pl: Uczeń", + "https://uonetplus-uczen.eduportal.koszalin.pl/koszalin", + "Zaloguj", + ), + Domain( + "eduportal.koszalin.pl: Wiadomości Plus", + "https://uonetplus-wiadomosciplus.eduportal.koszalin.pl/koszalin", + "Zaloguj", + ), + Domain( + "eduportal.koszalin.pl: Aplikacja mobilna", + "https://uonetplus-komunikacja.eduportal.koszalin.pl/koszalin", + "UONET+ dla urządzeń mobilnych", + ), +] + + +class Result(Enum): + OK = 0 + DATABASE_UPDATE = 1 + BREAK = 2 + ERROR = 3 + TIMEOUT = 4 + UNKNOWN = 5 + + +class Status: + def __init__(self, state: Result, status_code: int | None, message: str | None) -> None: + self.state = state + self.status_code = status_code + self.message = message + + +async def check_status(http_client: aiohttp.ClientSession, url: str, expected_title: str) -> Status: + try: + response = await http_client.get(url, timeout=10) + except asyncio.TimeoutError as e: + return Status(Result.TIMEOUT, None, "Timeout") + except aiohttp.ClientError as e: + return Status(Result.ERROR, None, str(e)) + + soup = BeautifulSoup(await response.text(), "html.parser") + + if error_div := soup.find(id="MainPage_ErrorDiv"): + return Status(Result.ERROR, response.status, error_div.get_text()) + + title = soup.title.string if soup.title else None + + if title == expected_title: + return Status(Result.OK, response.status, None) + + return Status(Result.UNKNOWN, response.status, title) + + +async def check_all(http_client: aiohttp.ClientSession) -> list[tuple[str, Status]]: + status = await asyncio.gather( + *(check_status(http_client, domain.host, domain.expected_title) for domain in DOMAINS) + ) + + return [(domain.name, result) for (domain, result) in zip(DOMAINS, status)]