diff --git a/AGENTS.md b/AGENTS.md index 8f8b417..3174f01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ python bot.py | Команда | Описание | Формат вывода | |---------|----------|---------------| -| `!pogoda` | Погода в Магнитогорске | Температура, ощущается, описание, влажность, ветер, давление | +| `!pg` | Погода в Магнитогорске | Температура, ощущается, описание, влажность, ветер, давление | | `!news` | Топ-5 статей и топ-5 новостей AI с Habr | Два блока: статьи и новости | | `!morning` | Погода + топ-5 статей + топ-5 новостей + котик | Embed: котик thumbnail, погода, статьи, новости | | `!cat` | Случайный котик | Embed с изображением | @@ -50,7 +50,7 @@ python bot.py ## API и внешние сервисы -### Погода (!pogoda, !morning) +### Погода (!pg, !morning) - **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа) - **Fallback**: `api.open-meteo.com` (бесплатный, без ключа) - Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках @@ -89,7 +89,7 @@ requests>=2.31.0 ``` ## Структура данных погоды -Команда `!pogoda` возвращает: +Команда `!pg` возвращает: ``` Температура: X°C (ощущается как Y°C) Описание: Z diff --git a/README.md b/README.md index 21ccb33..7af4940 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ python bot.py bot.py # Точка входа, инициализация бота, console_input() commands/ # Discord команды (cogs) __init__.py # ALL_COMMANDS — явные импорты - pogoda.py # !pogoda — погода с retry + fallback + pg.py # !pg — погода с retry + fallback news.py # !news — новости с Habr cat.py # !cat — случайный котик morning.py # !morning — утренний дайджест @@ -77,7 +77,7 @@ tests/ # pytest-тесты test_fetch_rss.py # fetch_rss test_fetch_weather.py # fetch_weather, fetch_open_meteo test_format_articles.py # truncate_title, parse_date, format_articles - test_commands_pogoda.py # Pogoda cog + test_commands_pg.py # Pg cog ISSUES.md # Задачи и баг-трекер проекта pytest.ini # Конфигурация pytest ``` @@ -109,13 +109,13 @@ python -m pytest tests/ -v | `test_fetch_rss.py` | `fetch_rss()` | 20 | | `test_fetch_weather.py` | `fetch_weather()`, `fetch_open_meteo()` | 20 | | `test_format_articles.py` | `truncate_title()`, `parse_date()`, `format_articles()` | 24 | -| `test_commands_pogoda.py` | `Pogoda` cog, команда `!pogoda` | 13 | +| `test_commands_pg.py` | `Pg` cog, команда `!pg` | 13 | **Итого: 180 тестов.** ## API и внешние сервисы -### Погода (!pogoda, !morning) +### Погода (!pg, !morning) - **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа) - **Fallback**: `api.open-meteo.com` (бесплатный, без ключа) - Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках @@ -140,7 +140,7 @@ python -m pytest tests/ -v ## Структура данных погоды -Команда `!pogoda` возвращает: +Команда `!pg` возвращает: ``` Температура: X°C (ощущается как Y°C) diff --git a/commands/__init__.py b/commands/__init__.py index 2aacde6..d7ee136 100644 --- a/commands/__init__.py +++ b/commands/__init__.py @@ -1,6 +1,6 @@ -from .pogoda import Pogoda +from .pg import Pg from .news import News from .cat import Cat from .morning import Morning -ALL_COMMANDS = [Pogoda, News, Cat, Morning] +ALL_COMMANDS = [Pg, News, Cat, Morning] diff --git a/commands/pogoda.py b/commands/pg.py similarity index 88% rename from commands/pogoda.py rename to commands/pg.py index 3924a22..d3adce2 100644 --- a/commands/pogoda.py +++ b/commands/pg.py @@ -3,14 +3,14 @@ from discord.ext import commands from utils.pogoda import fetch_weather, fetch_open_meteo, wmo_to_russian, translate_weather, pressure_to_mmhg -class Pogoda(commands.Cog): - """Команда !pogoda — прогноз погоды для Магнитогорска""" +class Pg(commands.Cog): + """Команда !pg — прогноз погоды для Магнитогорска""" def __init__(self): self.api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru" - @commands.command(name="pogoda") - async def pogoda(self, ctx): + @commands.command(name="pg") + async def pg(self, ctx): data = await fetch_weather(self.api_url) if data is None: return diff --git a/docker-audit.md b/docker-audit.md deleted file mode 100644 index 47e42c4..0000000 --- a/docker-audit.md +++ /dev/null @@ -1,71 +0,0 @@ -# Docker Audit — Discord Bot - -## ✅ Что уже есть - -| Файл | Статус | -|--------------------|---------| -| Dockerfile | ✅ Есть | -| docker-compose.yml | ✅ Есть | -| .dockerignore | ✅ Есть | -| requirements.txt | ✅ Есть | -| .env.example | ✅ Есть | - -## ⚠️ Что можно улучшить - -### 1. Dockerfile копирует весь проект целиком -Включая тесты, `console_commands/`, `.git`, `.pytest_cache` и прочее. -Стоит копировать только нужное: `bot.py`, `commands/`, `utils/`. - -### 2. Версия Python -Dockerfile использует `python:3.12-slim`, но локально Python 3.14. -Discord.py 2.3.2+ поддерживает 3.12+, но стоит убедиться, что зависимости совместимы. -Обновить базовый образ до `python:3.14-slim`. - -### 3. Нет .env в контейнере -docker-compose передаёт переменные через `environment:`, это работает, -но бот при запуске читает `.env` через `python-dotenv`. -Если запустить контейнер без `.env` на хосте, `DISCORD_TOKEN` не подтянется. -Решение: копировать `.env` в образ или убедиться, что `environment:` в compose передаёт все нужные переменные. - -### 4. Нет healthcheck -Нет способа проверить, жив ли бот через Docker. - -### 5. Нет ARG для тега образа -Нет возможности указать версию Python при сборке. - ---- - -## 📋 План улучшений - -### 1. Dockerfile -- Обновить базовый образ до `python:3.14-slim` -- Добавить `ARG PYTHON_VERSION` для гибкости -- Оптимизировать COPY: копировать только `bot.py`, `commands/`, `utils/` -- Добавить `.env` в образ (или убедиться, что env vars передаются корректно) -- Добавить healthcheck -- Добавить `.dockerenv` или аналог для предотвращения копирования лишних файлов - -### 2. docker-compose.yml -- Добавить healthcheck для сервиса бота -- Добавить volumes для логов -- Убедиться, что все переменные из `.env` передаются в контейнер -- Добавить restart policy - -### 3. .dockerignore -- Добавить `.vscode/` -- Добавить `__pycache__/` (уже есть, проверить) -- Добавить `.pytest_cache/` -- Добавить `node_modules/` -- Добавить `*.pyc` - ---- - -## 🚀 Приоритет - -| Приоритет | Изменение | Почему | -|-----------|-----------|--------| -| 🔴 Высокий | Оптимизировать COPY | Уменьшает размер образа, ускоряет сборку | -| 🔴 Высокий | Добавить healthcheck | Мониторинг состояния бота | -| 🟡 Средний | Обновить Python до 3.14 | Актуальность, но не критично | -| 🟡 Средний | Добавить volumes для логов | Удобство отладки | -| 🟢 Низкий | Добавить ARG для тега | Удобство, но не обязательно | diff --git a/tests/test_commands_pogoda.py b/tests/test_commands_pg.py similarity index 70% rename from tests/test_commands_pogoda.py rename to tests/test_commands_pg.py index 00034c4..0f35fcf 100644 --- a/tests/test_commands_pogoda.py +++ b/tests/test_commands_pg.py @@ -1,23 +1,23 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from commands.pogoda import Pogoda +from commands.pg import Pg -class TestPogodaInit: - """Тесты инициализации Cog Pogoda.""" +class TestPgInit: + """Тесты инициализации Cog Pg.""" def test_init_sets_api_url(self): """__init__ должен устанавливать api_url.""" - cog = Pogoda() + cog = Pg() assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru" -class TestPogodaCommand: +class TestPgCommand: """Тесты команды !pogoda.""" def _make_cog(self): - return Pogoda() + return Pg() def _make_ctx(self, send_return=None): ctx = MagicMock() @@ -42,14 +42,14 @@ class TestPogodaCommand: return defaults @pytest.mark.asyncio - async def test_pogoda_success(self): + async def test_pg_success(self): """Успешный запрос погоды должен отправить embed с данными.""" cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data() - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) ctx.send.assert_called_once() args = ctx.send.call_args[0][0] @@ -61,80 +61,80 @@ class TestPogodaCommand: assert "Давление: 759.8 мм рт. ст." in args @pytest.mark.asyncio - async def test_pogoda_fetch_returns_none(self): + async def test_pg_fetch_returns_none(self): """fetch_weather вернул None — бот должен ничего не отправить.""" cog = self._make_cog() ctx = self._make_ctx() - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=None)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=None)): + await cog.pg.callback(cog, ctx) ctx.send.assert_not_called() @pytest.mark.asyncio - async def test_pogoda_empty_current_condition(self): + async def test_pg_empty_current_condition(self): """current_condition пустой список — код выбрасывает IndexError (баг в коде).""" cog = self._make_cog() ctx = self._make_ctx() weather = {"current_condition": []} - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): with pytest.raises(IndexError): - await cog.pogoda.callback(cog, ctx) + await cog.pg.callback(cog, ctx) @pytest.mark.asyncio - async def test_pogoda_current_condition_none(self): + async def test_pg_current_condition_none(self): """current_condition — пустой dict — бот должен сообщить об ошибке.""" cog = self._make_cog() ctx = self._make_ctx() weather = {"current_condition": [{}]} - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) ctx.send.assert_called_once_with("Не удалось получить данные о погоде.") @pytest.mark.asyncio - async def test_pogoda_wind_non_numeric(self): + async def test_pg_wind_non_numeric(self): """windspeedKmph — не число — wind должен быть '—'.""" cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data(windspeedKmph="abc") - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] assert "Ветер: — м/с" in args @pytest.mark.asyncio - async def test_pogoda_wind_none(self): + async def test_pg_wind_none(self): """windspeedKmph отсутствует — wind должен быть '—'.""" cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data(windspeedKmph=None) - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] assert "Ветер: — м/с" in args @pytest.mark.asyncio - async def test_pogoda_zero_wind(self): + async def test_pg_zero_wind(self): """windspeedKmph = 0 — wind должен быть 0.0.""" cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data(windspeedKmph="0") - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] assert "Ветер: 0.0 м/с" in args @pytest.mark.asyncio - async def test_pogoda_default_values(self): + async def test_pg_default_values(self): """Поля с отсутствующими значениями должны давать '—'.""" cog = self._make_cog() ctx = self._make_ctx() @@ -146,8 +146,8 @@ class TestPogodaCommand: pressure=None, ) - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] # dict.get(key, default) возвращает None, если ключ есть, но значение None @@ -158,53 +158,53 @@ class TestPogodaCommand: assert "Давление: — мм рт. ст." in args @pytest.mark.asyncio - async def test_pogoda_translate_unknown_weather(self): + async def test_pg_translate_unknown_weather(self): """Неизвестное описание погоды должно возвращать оригинал.""" cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data(weatherDesc=[{"value": "UnknownXYZ"}]) - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] assert "Описание: UnknownXYZ" in args @pytest.mark.asyncio - async def test_pogoda_russian_weather_description(self): + async def test_pg_russian_weather_description(self): """Описание погоды на русском должно корректно переводиться.""" cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data(weatherDesc=[{"value": "Переменная облачность"}]) - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] assert "Описание: Переменная облачность" in args @pytest.mark.asyncio - async def test_pogoda_negative_pressure(self): + async def test_pg_negative_pressure(self): """Отрицательное давление должно конвертироваться.""" cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data(pressure="-50") - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] assert "Давление: -37.5 мм рт. ст." in args @pytest.mark.asyncio - async def test_pogoda_high_wind(self): + async def test_pg_high_wind(self): """Большая скорость ветра должна корректно округляться.""" cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data(windspeedKmph="123") - with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): - await cog.pogoda.callback(cog, ctx) + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] assert "Ветер: 34.2 м/с" in args