feat: добавлены консольные команды logs, reload, trigger morning

- console_commands/logs.py — чтение последних строк лога (tail -20)
- console_commands/reload.py — горячая перезагрузка всех cogs
- console_commands/trigger_morning.py — ручной запуск morning-дайджеста
- logs доступна через admin.py (docker exec) и интерактивный терминал
- reload и trigger morning доступны через интерактивный терминал
- сохранён bot._scheduler для доступа к планировщику
- обновлены __init__.py, admin.py, README.md, ISSUES.md
- добавлены тесты: test_console_logs.py (4), test_console_reload.py (2), test_console_trigger_morning.py (3)
- итого 243 теста
This commit is contained in:
deadzilla 2026-06-12 18:03:47 +05:00
parent 5c438b4c48
commit c37baafacc
12 changed files with 333 additions and 15 deletions

View File

@ -16,9 +16,7 @@
### Средний приоритет
- [ ] **`reload`** — горячая перезагрузка cogs без остановки бота
- [ ] **`logs`** — последние строки лога (tail -20)
- [ ] **`trigger morning`** — ручной запуск morning-дайджеста в канал
- [
### Низкий приоритет

View File

@ -54,6 +54,9 @@ python bot.py
| 5 | `help` | Показать список всех команд |
| 6 | `status` | Статус бота: пинг и uptime |
| 7 | `stats` | Количество серверов, каналов, пользователей |
| 8 | `logs` | Последние строки лога (tail -20) |
| 9 | `reload` | Горячая перезагрузка всех cogs |
| 10 | `trigger morning` | Ручной запуск morning-дайджеста |
| 0 | `stop` | Остановка бота |
> Номера команд генерируются автоматически из `ALL_CONSOLE_COMMANDS` в порядке определения в `console_commands/__init__.py`.
@ -82,6 +85,9 @@ console_commands/ # Консольные команды
help.py # help — список всех команд
status.py # status — статус бота в терминале
stats.py # stats — статистика серверов в терминале
logs.py # logs — последние строки лога (tail)
reload.py # reload — горячая перезагрузка cogs
trigger_morning.py # trigger morning — ручной запуск morning-дайджеста
utils/ # Утилиты (API-клиенты, конвертации)
__init__.py # __all__ — публичный API утилит
pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_embed()
@ -148,8 +154,11 @@ python -m pytest tests/ -v
| `test_admin.py` | `admin.py` — CLI для docker exec | 5 |
| `test_commands_status.py` | команда `!status` (embed, uptime) | 6 |
| `test_commands_stats.py` | команда `!stats` (серверы, каналы) | 4 |
| `test_console_logs.py` | команда `logs` (чтение лога) | 4 |
| `test_console_reload.py` | команда `reload` (перезагрузка cogs) | 2 |
| `test_console_trigger_morning.py` | команда `trigger morning` (запуск дайджеста) | 3 |
**Итого: 233 теста.**
**Итого: 243 теста.**
## Запуск в Docker
@ -181,10 +190,13 @@ 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 logs # последние строки лога
docker exec discord-bot python admin.py help
docker stop discord-bot # остановка бота
docker stop discord-bot # остановка бота
```
Команды `reload` и `trigger morning` доступны только через интерактивный терминал бота (требуют запущенного экземпляра бота).
Результат выводится в stdout терминала. Команды используют те же `utils`, что и Discord-команды.
## API и внешние сервисы

1
bot.py
View File

@ -55,6 +55,7 @@ class BotRunner:
# Запуск планировщика
morning_time = os.getenv("MORNING_TIME", "07:00")
self.scheduler = Scheduler(self.bot, morning_time)
self.bot._scheduler = self.scheduler
logger.info(f" Планировщик запущен (время: {morning_time})")
self.bot_ready.set()

View File

@ -6,6 +6,9 @@ from .morning import morning
from .help import help
from .status import status
from .stats import stats
from .logs import logs
from .reload import reload
from .trigger_morning import trigger_morning
ALL_CONSOLE_COMMANDS = {
"stop": stop,
@ -16,4 +19,7 @@ ALL_CONSOLE_COMMANDS = {
"help": help,
"status": status,
"stats": stats,
"logs": logs,
"reload": reload,
"trigger morning": trigger_morning,
}

View File

@ -11,23 +11,56 @@ import sys
import asyncio
async def run_logs():
"""Вывести последние строки лога."""
from console_commands.logs import LOG_FILE, DEFAULT_LINES
lines = DEFAULT_LINES
if len(sys.argv) > 2:
try:
lines = int(sys.argv[2])
except ValueError:
pass
from pathlib import Path
log_file = LOG_FILE
if not log_file.exists():
print(f"Файл лога не найден: {log_file}")
return
try:
with open(log_file, "r", encoding="utf-8") as f:
all_lines = f.readlines()
tail = all_lines[-lines:] if len(all_lines) > lines else all_lines
print(f"\nПоследние {len(tail)} строк {log_file}:")
print("-" * 40)
print("".join(tail), end="")
print("-" * 40)
except OSError as e:
print(f"Ошибка чтения лога: {e}")
def print_help():
"""Показать список команд."""
print("\nАдмин-команды:")
print("-" * 40)
commands = [
("pogoda", "Прогноз погоды в Магнитогорске"),
("news", "Топ-5 статей и новостей AI с Habr"),
("cat", "URL случайного котика"),
("morning", "Утренний дайджест: погода + новости + котик"),
("help", "Показать этот список"),
("pogoda", "Прогноз погоды в Магнитогорске"),
("news", "Топ-5 статей и новостей AI с Habr"),
("cat", "URL случайного котика"),
("morning", "Утренний дайджест: погода + новости + котик"),
("logs", "Последние строки лога (tail -20)"),
("help", "Показать этот список"),
]
for name, desc in commands:
print(f" {name:<12}{desc}")
print("-" * 40)
print("\n status — через Discord: !status")
print(" stats — через Discord: !stats")
print(" stop — docker stop discord-bot\n")
print("\n status — через Discord: !status")
print(" stats — через Discord: !stats")
print(" reload — через терминал бота (интерактив)")
print(" trigger morning — через терминал бота (интерактив)")
print(" stop — docker stop discord-bot\n")
async def run_pogoda():
@ -171,6 +204,7 @@ COMMANDS = {
"news": run_news,
"cat": run_cat,
"morning": run_morning,
"logs": run_logs,
"help": lambda: print_help(),
}

28
console_commands/logs.py Normal file
View File

@ -0,0 +1,28 @@
"""Просмотр последних строк лога."""
from pathlib import Path
LOG_FILE = Path("logs/bot.log")
DEFAULT_LINES = 20
def logs(stop_event, bot, lines: int = DEFAULT_LINES):
"""Показать последние строки лога (tail -N)."""
if stop_event.is_set():
return None
if not LOG_FILE.exists():
print(f"Файл лога не найден: {LOG_FILE}")
return None
try:
with open(LOG_FILE, "r", encoding="utf-8") as f:
all_lines = f.readlines()
tail = all_lines[-lines:] if len(all_lines) > lines else all_lines
print(f"\nПоследние {len(tail)} строк {LOG_FILE}:")
print("-" * 40)
print("".join(tail), end="")
print("-" * 40)
except OSError as e:
print(f"Ошибка чтения лога: {e}")

View File

@ -0,0 +1,23 @@
"""Горячая перезагрузка cogs без остановки бота."""
from commands import ALL_COMMANDS
def reload(stop_event, bot):
"""Перезагрузить все cogs бота."""
if stop_event.is_set():
return None
# Удалить все текущие cogs
cog_names = list(bot.cogs.keys())
for cog_name in cog_names:
bot.remove_cog(cog_name)
# Пересоздать и добавить все cogs
reloaded = []
for cog_class in ALL_COMMANDS:
cog = cog_class()
bot.add_cog(cog)
reloaded.append(cog.__class__.__name__)
print(f"\nПерезагружено cogs: {', '.join(reloaded)}")

View File

@ -0,0 +1,22 @@
"""Ручной запуск morning-дайджеста в канал."""
import asyncio
def trigger_morning(stop_event, bot):
"""Ручной запуск morning-дайджеста через scheduler."""
if stop_event.is_set():
return None
scheduler = getattr(bot, "_scheduler", None)
if scheduler is None:
print("Планировщик не запущен. Утренний дайджест недоступен.")
return None
async def _run():
await scheduler._run_morning()
# Запустить async-метод в event loop бота
future = asyncio.run_coroutine_threadsafe(_run(), bot.loop)
future.result()
print("\nУтренний дайджест запущен вручную.")

View File

@ -55,11 +55,11 @@ class TestAdminImport:
assert hasattr(admin, "main")
assert hasattr(admin, "COMMANDS")
assert len(admin.COMMANDS) == 5
assert len(admin.COMMANDS) == 6
def test_commands_mapping(self):
"""Все команды зарегистрированы в маппинге."""
from console_commands.admin import COMMANDS
expected = {"pogoda", "news", "cat", "morning", "help"}
expected = {"pogoda", "news", "cat", "morning", "logs", "help"}
assert set(COMMANDS.keys()) == expected

View File

@ -0,0 +1,87 @@
"""Тесты для console_commands/logs.py."""
import importlib
from pathlib import Path
from unittest.mock import MagicMock
class TestLogsCommand:
"""Тесты консольной команды logs."""
def _get_module(self):
"""Импортировать модуль logs."""
return importlib.import_module("console_commands.logs")
def _get_logs_func(self):
"""Импортировать функцию logs."""
from console_commands.logs import logs
return logs
def test_logs_no_file(self, capfd):
"""Выводит сообщение если файл лога не существует."""
stop_event = MagicMock()
stop_event.is_set.return_value = False
bot = MagicMock()
mod = self._get_module()
original = mod.LOG_FILE
try:
mod.LOG_FILE = Path("/nonexistent/bot.log")
self._get_logs_func()(stop_event, bot)
finally:
mod.LOG_FILE = original
captured = capfd.readouterr()
assert "не найден" in captured.out
def test_logs_reads_last_lines(self, capfd, tmp_path):
"""Выводит последние N строк лога."""
log_file = tmp_path / "bot.log"
lines = [f"Line {i}\n" for i in range(30)]
log_file.write_text("".join(lines))
stop_event = MagicMock()
stop_event.is_set.return_value = False
bot = MagicMock()
mod = self._get_module()
original = mod.LOG_FILE
try:
mod.LOG_FILE = log_file
self._get_logs_func()(stop_event, bot, lines=5)
finally:
mod.LOG_FILE = original
captured = capfd.readouterr()
assert "Line 25" in captured.out
assert "Line 29" in captured.out
assert "Line 1" not in captured.out
def test_logs_fewer_lines_than_requested(self, capfd, tmp_path):
"""Если строк меньше N, выводит все."""
log_file = tmp_path / "bot.log"
log_file.write_text("Line 1\nLine 2\n")
stop_event = MagicMock()
stop_event.is_set.return_value = False
bot = MagicMock()
mod = self._get_module()
original = mod.LOG_FILE
try:
mod.LOG_FILE = log_file
self._get_logs_func()(stop_event, bot, lines=10)
finally:
mod.LOG_FILE = original
captured = capfd.readouterr()
assert "Line 1" in captured.out
assert "Line 2" in captured.out
def test_logs_stop_event(self):
"""Не выполняется если stop_event установлен."""
stop_event = MagicMock()
stop_event.is_set.return_value = True
bot = MagicMock()
result = self._get_logs_func()(stop_event, bot)
assert result is None

View File

@ -0,0 +1,54 @@
"""Тесты для console_commands/reload.py."""
import importlib
from unittest.mock import MagicMock
class TestReloadCommand:
"""Тесты консольной команды reload."""
def _get_module(self):
"""Импортировать модуль reload."""
return importlib.reload(importlib.import_module("console_commands.reload"))
def test_reload_removes_and_adds_cogs(self, capfd):
"""Удаляет старые cogs и добавляет новые."""
stop_event = MagicMock()
stop_event.is_set.return_value = False
bot = MagicMock()
bot.cogs = {"TestCog1": MagicMock(), "TestCog2": MagicMock()}
cog_class1 = MagicMock()
cog_class1.__name__ = "TestCog1"
cog_instance1 = MagicMock()
cog_instance1.__class__.__name__ = "TestCog1"
cog_class1.return_value = cog_instance1
cog_class2 = MagicMock()
cog_class2.__name__ = "TestCog2"
cog_instance2 = MagicMock()
cog_instance2.__class__.__name__ = "TestCog2"
cog_class2.return_value = cog_instance2
mod = self._get_module()
original = mod.ALL_COMMANDS
try:
mod.ALL_COMMANDS = [cog_class1, cog_class2]
mod.reload(stop_event, bot)
finally:
mod.ALL_COMMANDS = original
assert bot.remove_cog.call_count == 2
assert bot.add_cog.call_count == 2
captured = capfd.readouterr()
assert "Перезагружено" in captured.out
def test_reload_stop_event(self):
"""Не выполняется если stop_event установлен."""
stop_event = MagicMock()
stop_event.is_set.return_value = True
bot = MagicMock()
mod = self._get_module()
result = mod.reload(stop_event, bot)
assert result is None

View File

@ -0,0 +1,53 @@
"""Тесты для console_commands/trigger_morning.py."""
import importlib
from unittest.mock import MagicMock, patch
class TestTriggerMorningCommand:
"""Тесты консольной команды trigger morning."""
def test_trigger_morning_no_scheduler(self, capfd):
"""Выводит сообщение если scheduler не найден."""
stop_event = MagicMock()
stop_event.is_set.return_value = False
bot = MagicMock()
# Убедимся что _scheduler не установлен через getattr
type(bot)._scheduler = None
mod = importlib.import_module("console_commands.trigger_morning")
mod.trigger_morning(stop_event, bot)
captured = capfd.readouterr()
assert "не запущен" in captured.out
def test_trigger_morning_stop_event(self):
"""Не выполняется если stop_event установлен."""
stop_event = MagicMock()
stop_event.is_set.return_value = True
bot = MagicMock()
mod = importlib.import_module("console_commands.trigger_morning")
result = mod.trigger_morning(stop_event, bot)
assert result is None
def test_trigger_morning_calls_scheduler(self, capfd):
"""Вызывает scheduler._run_morning()."""
stop_event = MagicMock()
stop_event.is_set.return_value = False
scheduler = MagicMock()
bot = MagicMock()
bot._scheduler = scheduler
mock_future = MagicMock()
mock_future.result.return_value = None
mod = importlib.import_module("console_commands.trigger_morning")
with patch.object(
mod, "asyncio",
MagicMock(run_coroutine_threadsafe=MagicMock(return_value=mock_future))
):
mod.trigger_morning(stop_event, bot)
captured = capfd.readouterr()
assert "запущен вручную" in captured.out