feat: admin.py для управления ботом через docker exec

- Добавлен console_commands/admin.py — CLI-скрипт с командами
  pogoda, news, cat, morning, help
- Добавлен docstring к методу pg (отображается в !hp)
- Обновлён README.md: архитектура, администрирование в Docker, тесты
- Добавлены тесты test_admin.py (5 тестов)
- Итого тестов: 223
This commit is contained in:
deadzilla 2026-06-12 16:06:35 +05:00
parent a52bb94224
commit 12ed515cca
4 changed files with 285 additions and 1 deletions

View File

@ -67,6 +67,7 @@ commands/ # Discord команды (cogs)
help.py # !hp — список команд (автогенерация из bot.commands)
console_commands/ # Консольные команды
__init__.py # ALL_CONSOLE_COMMANDS — явные импорты
admin.py # admin — CLI для docker exec (pogoda, news, cat, morning, help)
stop.py # stop — остановка бота
news.py # news — новости с Habr
pogoda.py # pogoda — погода в терминале
@ -91,6 +92,8 @@ tests/ # pytest-тесты
test_morning_runner.py# тесты morning runner-а
test_help_discord.py # команда !hp — проверка формата вывода и контента
test_help_console.py # консольная help — проверка списка команд
test_logger.py # setup_logging — уровни, обработчики, формат
test_admin.py # admin.py — CLI-скрипт для docker exec
ISSUES.md # Задачи и баг-трекер проекта
pytest.ini # Конфигурация pytest (asyncio_mode = auto)
Dockerfile # Сборка образа бота (Python 3.14-slim, healthcheck)
@ -130,9 +133,11 @@ python -m pytest tests/ -v
| `test_morning_runner.py` | morning runner-а | 68 |
| `test_help_discord.py` | команда `!hp` | 2 |
| `test_help_console.py` | консольная `help` | 2 |
| `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 |
| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 |
| `test_admin.py` | `admin.py` — CLI для docker exec | 5 |
**Итого: 209 тестов.**
**Итого: 223 теста.**
## Запуск в Docker
@ -155,6 +160,21 @@ DISCORD_TOKEN=ваш_токен docker-compose up
- Консольный ввод отключён в Docker (stdin недоступен)
- Версия Python настраивается через `ARG PYTHON_VERSION`
### Администрирование через docker exec
Для управления ботом из терминала (без Discord-чата) используйте `admin.py`:
```bash
docker exec discord-bot python admin.py pogoda
docker exec discord-bot python admin.py news
docker exec discord-bot python admin.py cat
docker exec discord-bot python admin.py morning
docker exec discord-bot python admin.py help
docker stop discord-bot # остановка бота
```
Результат выводится в stdout терминала. Команды используют те же `utils`, что и Discord-команды.
## API и внешние сервисы
### Погода (!pg, !morning)

View File

@ -10,6 +10,7 @@ class Pg(commands.Cog):
@commands.command(name="pg")
async def pg(self, ctx):
"""Прогноз погоды в Магнитогорске"""
data = await fetch_weather(self.api_url)
if data is None:
await ctx.send("Не удалось получить данные о погоде.")

198
console_commands/admin.py Normal file
View File

@ -0,0 +1,198 @@
"""Админ-скрипт для управления ботом через docker exec.
Использование:
docker exec discord-bot python admin.py pogoda
docker exec discord-bot python admin.py news
docker exec discord-bot python admin.py cat
docker exec discord-bot python admin.py morning
docker exec discord-bot python admin.py help
"""
import sys
import asyncio
def print_help():
"""Показать список команд."""
print("\nАдмин-команды:")
print("-" * 40)
commands = [
("pogoda", "Прогноз погоды в Магнитогорске"),
("news", "Топ-5 статей и новостей AI с Habr"),
("cat", "URL случайного котика"),
("morning", "Утренний дайджест: погода + новости + котик"),
("help", "Показать этот список"),
]
for name, desc in commands:
print(f" {name:<12}{desc}")
print("-" * 40)
print("\nОстановка бота: docker stop discord-bot\n")
async def run_pogoda():
"""Вывести прогноз погоды для Магнитогорска."""
from utils.pogoda import (
API_URL_WEATHER,
fetch_weather,
format_weather_data_for_console,
)
data = await fetch_weather(API_URL_WEATHER)
if data is None:
print("Не удалось получить данные о погоде.")
return
formatted = format_weather_data_for_console(data)
if not formatted:
print("Не удалось получить данные о погоде.")
return
for line in formatted:
print(line)
async def run_news():
"""Вывести топ-5 свежих статей по AI с Habr."""
from utils.news import (
RSS_URL_ARTICLES,
RSS_URL_POSTS,
fetch_rss,
format_articles,
)
articles = await fetch_rss(RSS_URL_ARTICLES)
if articles is None:
print("Не удалось получить новости.")
return
if not articles:
print("Новостей пока нет.")
return
lines = format_articles(
articles,
"Статьи AI / Хабр",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
)
posts = await fetch_rss(RSS_URL_POSTS)
if posts is not None and posts:
lines.append("")
lines.extend(
format_articles(
posts,
"Новости AI / Хабр",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
)
)
for line in lines:
print(line)
async def run_cat():
"""Вывести URL случайного котика."""
from utils.cat import fetch_cat
url = await fetch_cat()
if url is None:
print("Не удалось получить котика.")
return
print(f"Котик: {url}")
async def run_morning():
"""Вывести утренний дайджест: погода + новости + котик."""
from utils.cat import fetch_cat
from utils.morning_runner import gather_morning
from utils.news import format_articles
from utils.pogoda import format_weather_data_for_console
data = await gather_morning()
print("Доброе утро!\n")
# --- Котик ---
if data.cat_url:
print(f"Котик: {data.cat_url}\n")
else:
print("Котика получить не удалось.\n")
# --- Погода ---
formatted = format_weather_data_for_console(data.weather)
if formatted:
print("**Погода в Магнитогорске:**")
for line in formatted:
print(line)
else:
print("Не удалось получить данные о погоде.")
print()
# --- Новости: статьи ---
if data.articles is not None:
if data.articles:
print(
"\n".join(
format_articles(
data.articles,
"Статьи AI / Хабр",
"https://habr.com/ru/hubs/"
"artificial_intelligence/articles/top/daily/",
)
)
)
else:
print("Новостей пока нет.")
else:
print("Не удалось получить новости.")
print()
# --- Новости: посты ---
if data.posts is not None:
if data.posts:
print(
"\n".join(
format_articles(
data.posts,
"Новости AI / Хабр",
"https://habr.com/ru/hubs/"
"artificial_intelligence/news/top/daily/",
)
)
)
else:
print("Новостей пока нет.")
else:
print("Не удалось получить новости.")
COMMANDS = {
"pogoda": run_pogoda,
"news": run_news,
"cat": run_cat,
"morning": run_morning,
"help": lambda: print_help(),
}
def main():
"""Точка входа: парсит аргумент и вызывает команду."""
if len(sys.argv) < 2:
print("Укажите команду.")
print_help()
sys.exit(1)
command = sys.argv[1].lower()
handler = COMMANDS.get(command)
if handler is None:
print(f"Неизвестная команда: {command}")
print_help()
sys.exit(1)
if command == "help":
handler()
else:
asyncio.run(handler())
if __name__ == "__main__":
main()

65
tests/test_admin.py Normal file
View File

@ -0,0 +1,65 @@
"""Тесты для admin.py — управление через docker exec."""
import subprocess
import sys
import pytest
ADMIN_SCRIPT = "console_commands/admin.py"
class TestAdminHelp:
"""Тесты команды help."""
def test_help_shows_commands(self, capfd):
"""Команда help выводит список команд."""
result = subprocess.run(
[sys.executable, ADMIN_SCRIPT, "help"],
capture_output=True,
text=True,
)
assert result.returncode == 0
output = result.stdout
assert "pogoda" in output
assert "news" in output
assert "cat" in output
assert "morning" in output
assert "help" in output
def test_no_args_shows_help(self, capfd):
"""Без аргументов выводит help и завершается с кодом 1."""
result = subprocess.run(
[sys.executable, ADMIN_SCRIPT],
capture_output=True,
text=True,
)
assert result.returncode == 1
assert "pogoda" in result.stdout
def test_unknown_command(self, capfd):
"""Неизвестная команда завершается с кодом 1."""
result = subprocess.run(
[sys.executable, ADMIN_SCRIPT, "unknown"],
capture_output=True,
text=True,
)
assert result.returncode == 1
assert "unknown" in result.stdout.lower()
class TestAdminImport:
"""Тесты импорта модуля."""
def test_admin_module_imports(self):
"""Модуль admin импортируется без ошибок."""
from console_commands import admin
assert hasattr(admin, "main")
assert hasattr(admin, "COMMANDS")
assert len(admin.COMMANDS) == 5
def test_commands_mapping(self):
"""Все команды зарегистрированы в маппинге."""
from console_commands.admin import COMMANDS
expected = {"pogoda", "news", "cat", "morning", "help"}
assert set(COMMANDS.keys()) == expected