Переименовать команду погоды: pogoda -> pg, удалить docker-audit.md
This commit is contained in:
parent
6e31a7e6ff
commit
0d605eea5a
@ -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
|
||||
|
||||
10
README.md
10
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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
@ -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 для тега | Удобство, но не обязательно |
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user