Переименовать команду погоды: pogoda -> pg, удалить docker-audit.md

This commit is contained in:
deadzilla 2026-05-31 23:13:23 +05:00
parent 6e31a7e6ff
commit 0d605eea5a
6 changed files with 56 additions and 127 deletions

View File

@ -25,7 +25,7 @@ python bot.py
| Команда | Описание | Формат вывода | | Команда | Описание | Формат вывода |
|---------|----------|---------------| |---------|----------|---------------|
| `!pogoda` | Погода в Магнитогорске | Температура, ощущается, описание, влажность, ветер, давление | | `!pg` | Погода в Магнитогорске | Температура, ощущается, описание, влажность, ветер, давление |
| `!news` | Топ-5 статей и топ-5 новостей AI с Habr | Два блока: статьи и новости | | `!news` | Топ-5 статей и топ-5 новостей AI с Habr | Два блока: статьи и новости |
| `!morning` | Погода + топ-5 статей + топ-5 новостей + котик | Embed: котик thumbnail, погода, статьи, новости | | `!morning` | Погода + топ-5 статей + топ-5 новостей + котик | Embed: котик thumbnail, погода, статьи, новости |
| `!cat` | Случайный котик | Embed с изображением | | `!cat` | Случайный котик | Embed с изображением |
@ -50,7 +50,7 @@ python bot.py
## API и внешние сервисы ## API и внешние сервисы
### Погода (!pogoda, !morning) ### Погода (!pg, !morning)
- **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа) - **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа)
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа) - **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках - Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
@ -89,7 +89,7 @@ requests>=2.31.0
``` ```
## Структура данных погоды ## Структура данных погоды
Команда `!pogoda` возвращает: Команда `!pg` возвращает:
``` ```
Температура: X°C (ощущается как Y°C) Температура: X°C (ощущается как Y°C)
Описание: Z Описание: Z

View File

@ -56,7 +56,7 @@ python bot.py
bot.py # Точка входа, инициализация бота, console_input() bot.py # Точка входа, инициализация бота, console_input()
commands/ # Discord команды (cogs) commands/ # Discord команды (cogs)
__init__.py # ALL_COMMANDS — явные импорты __init__.py # ALL_COMMANDS — явные импорты
pogoda.py # !pogoda — погода с retry + fallback pg.py # !pg — погода с retry + fallback
news.py # !news — новости с Habr news.py # !news — новости с Habr
cat.py # !cat — случайный котик cat.py # !cat — случайный котик
morning.py # !morning — утренний дайджест morning.py # !morning — утренний дайджест
@ -77,7 +77,7 @@ tests/ # pytest-тесты
test_fetch_rss.py # fetch_rss test_fetch_rss.py # fetch_rss
test_fetch_weather.py # fetch_weather, fetch_open_meteo test_fetch_weather.py # fetch_weather, fetch_open_meteo
test_format_articles.py # truncate_title, parse_date, format_articles test_format_articles.py # truncate_title, parse_date, format_articles
test_commands_pogoda.py # Pogoda cog test_commands_pg.py # Pg cog
ISSUES.md # Задачи и баг-трекер проекта ISSUES.md # Задачи и баг-трекер проекта
pytest.ini # Конфигурация pytest pytest.ini # Конфигурация pytest
``` ```
@ -109,13 +109,13 @@ python -m pytest tests/ -v
| `test_fetch_rss.py` | `fetch_rss()` | 20 | | `test_fetch_rss.py` | `fetch_rss()` | 20 |
| `test_fetch_weather.py` | `fetch_weather()`, `fetch_open_meteo()` | 20 | | `test_fetch_weather.py` | `fetch_weather()`, `fetch_open_meteo()` | 20 |
| `test_format_articles.py` | `truncate_title()`, `parse_date()`, `format_articles()` | 24 | | `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 тестов.** **Итого: 180 тестов.**
## API и внешние сервисы ## API и внешние сервисы
### Погода (!pogoda, !morning) ### Погода (!pg, !morning)
- **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа) - **Основной**: `wttr.in/Magnitogorsk` (бесплатный, без ключа)
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа) - **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках - Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
@ -140,7 +140,7 @@ python -m pytest tests/ -v
## Структура данных погоды ## Структура данных погоды
Команда `!pogoda` возвращает: Команда `!pg` возвращает:
``` ```
Температура: X°C (ощущается как Y°C) Температура: X°C (ощущается как Y°C)

View File

@ -1,6 +1,6 @@
from .pogoda import Pogoda from .pg import Pg
from .news import News from .news import News
from .cat import Cat from .cat import Cat
from .morning import Morning from .morning import Morning
ALL_COMMANDS = [Pogoda, News, Cat, Morning] ALL_COMMANDS = [Pg, News, Cat, Morning]

View File

@ -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 from utils.pogoda import fetch_weather, fetch_open_meteo, wmo_to_russian, translate_weather, pressure_to_mmhg
class Pogoda(commands.Cog): class Pg(commands.Cog):
"""Команда !pogoda — прогноз погоды для Магнитогорска""" """Команда !pg — прогноз погоды для Магнитогорска"""
def __init__(self): def __init__(self):
self.api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru" self.api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
@commands.command(name="pogoda") @commands.command(name="pg")
async def pogoda(self, ctx): async def pg(self, ctx):
data = await fetch_weather(self.api_url) data = await fetch_weather(self.api_url)
if data is None: if data is None:
return return

View File

@ -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 для тега | Удобство, но не обязательно |

View File

@ -1,23 +1,23 @@
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from commands.pogoda import Pogoda from commands.pg import Pg
class TestPogodaInit: class TestPgInit:
"""Тесты инициализации Cog Pogoda.""" """Тесты инициализации Cog Pg."""
def test_init_sets_api_url(self): def test_init_sets_api_url(self):
"""__init__ должен устанавливать api_url.""" """__init__ должен устанавливать api_url."""
cog = Pogoda() cog = Pg()
assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru" assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
class TestPogodaCommand: class TestPgCommand:
"""Тесты команды !pogoda.""" """Тесты команды !pogoda."""
def _make_cog(self): def _make_cog(self):
return Pogoda() return Pg()
def _make_ctx(self, send_return=None): def _make_ctx(self, send_return=None):
ctx = MagicMock() ctx = MagicMock()
@ -42,14 +42,14 @@ class TestPogodaCommand:
return defaults return defaults
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_success(self): async def test_pg_success(self):
"""Успешный запрос погоды должен отправить embed с данными.""" """Успешный запрос погоды должен отправить embed с данными."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data() weather = self._make_weather_data()
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
ctx.send.assert_called_once() ctx.send.assert_called_once()
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
@ -61,80 +61,80 @@ class TestPogodaCommand:
assert "Давление: 759.8 мм рт. ст." in args assert "Давление: 759.8 мм рт. ст." in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_fetch_returns_none(self): async def test_pg_fetch_returns_none(self):
"""fetch_weather вернул None — бот должен ничего не отправить.""" """fetch_weather вернул None — бот должен ничего не отправить."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=None)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=None)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
ctx.send.assert_not_called() ctx.send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_empty_current_condition(self): async def test_pg_empty_current_condition(self):
"""current_condition пустой список — код выбрасывает IndexError (баг в коде).""" """current_condition пустой список — код выбрасывает IndexError (баг в коде)."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = {"current_condition": []} 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): with pytest.raises(IndexError):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_current_condition_none(self): async def test_pg_current_condition_none(self):
"""current_condition — пустой dict — бот должен сообщить об ошибке.""" """current_condition — пустой dict — бот должен сообщить об ошибке."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = {"current_condition": [{}]} 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)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.") ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_wind_non_numeric(self): async def test_pg_wind_non_numeric(self):
"""windspeedKmph — не число — wind должен быть ''.""" """windspeedKmph — не число — wind должен быть ''."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph="abc") weather = self._make_weather_data(windspeedKmph="abc")
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Ветер: — м/с" in args assert "Ветер: — м/с" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_wind_none(self): async def test_pg_wind_none(self):
"""windspeedKmph отсутствует — wind должен быть ''.""" """windspeedKmph отсутствует — wind должен быть ''."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph=None) weather = self._make_weather_data(windspeedKmph=None)
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Ветер: — м/с" in args assert "Ветер: — м/с" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_zero_wind(self): async def test_pg_zero_wind(self):
"""windspeedKmph = 0 — wind должен быть 0.0.""" """windspeedKmph = 0 — wind должен быть 0.0."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph="0") weather = self._make_weather_data(windspeedKmph="0")
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Ветер: 0.0 м/с" in args assert "Ветер: 0.0 м/с" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_default_values(self): async def test_pg_default_values(self):
"""Поля с отсутствующими значениями должны давать ''.""" """Поля с отсутствующими значениями должны давать ''."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
@ -146,8 +146,8 @@ class TestPogodaCommand:
pressure=None, pressure=None,
) )
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
# dict.get(key, default) возвращает None, если ключ есть, но значение None # dict.get(key, default) возвращает None, если ключ есть, но значение None
@ -158,53 +158,53 @@ class TestPogodaCommand:
assert "Давление: — мм рт. ст." in args assert "Давление: — мм рт. ст." in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_translate_unknown_weather(self): async def test_pg_translate_unknown_weather(self):
"""Неизвестное описание погоды должно возвращать оригинал.""" """Неизвестное описание погоды должно возвращать оригинал."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data(weatherDesc=[{"value": "UnknownXYZ"}]) weather = self._make_weather_data(weatherDesc=[{"value": "UnknownXYZ"}])
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Описание: UnknownXYZ" in args assert "Описание: UnknownXYZ" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_russian_weather_description(self): async def test_pg_russian_weather_description(self):
"""Описание погоды на русском должно корректно переводиться.""" """Описание погоды на русском должно корректно переводиться."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data(weatherDesc=[{"value": "Переменная облачность"}]) weather = self._make_weather_data(weatherDesc=[{"value": "Переменная облачность"}])
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Описание: Переменная облачность" in args assert "Описание: Переменная облачность" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_negative_pressure(self): async def test_pg_negative_pressure(self):
"""Отрицательное давление должно конвертироваться.""" """Отрицательное давление должно конвертироваться."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data(pressure="-50") weather = self._make_weather_data(pressure="-50")
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Давление: -37.5 мм рт. ст." in args assert "Давление: -37.5 мм рт. ст." in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pogoda_high_wind(self): async def test_pg_high_wind(self):
"""Большая скорость ветра должна корректно округляться.""" """Большая скорость ветра должна корректно округляться."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data(windspeedKmph="123") weather = self._make_weather_data(windspeedKmph="123")
with patch("commands.pogoda.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pogoda.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Ветер: 34.2 м/с" in args assert "Ветер: 34.2 м/с" in args