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` | Показать список всех команд | | 5 | `help` | Показать список всех команд |
| 6 | `status` | Статус бота: пинг и uptime | | 6 | `status` | Статус бота: пинг и uptime |
| 7 | `stats` | Количество серверов, каналов, пользователей | | 7 | `stats` | Количество серверов, каналов, пользователей |
| 8 | `logs` | Последние строки лога (tail -20) |
| 9 | `reload` | Горячая перезагрузка всех cogs |
| 10 | `trigger morning` | Ручной запуск morning-дайджеста |
| 0 | `stop` | Остановка бота | | 0 | `stop` | Остановка бота |
> Номера команд генерируются автоматически из `ALL_CONSOLE_COMMANDS` в порядке определения в `console_commands/__init__.py`. > Номера команд генерируются автоматически из `ALL_CONSOLE_COMMANDS` в порядке определения в `console_commands/__init__.py`.
@ -82,6 +85,9 @@ console_commands/ # Консольные команды
help.py # help — список всех команд help.py # help — список всех команд
status.py # status — статус бота в терминале status.py # status — статус бота в терминале
stats.py # stats — статистика серверов в терминале stats.py # stats — статистика серверов в терминале
logs.py # logs — последние строки лога (tail)
reload.py # reload — горячая перезагрузка cogs
trigger_morning.py # trigger morning — ручной запуск morning-дайджеста
utils/ # Утилиты (API-клиенты, конвертации) utils/ # Утилиты (API-клиенты, конвертации)
__init__.py # __all__ — публичный 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() 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_admin.py` | `admin.py` — CLI для docker exec | 5 |
| `test_commands_status.py` | команда `!status` (embed, uptime) | 6 | | `test_commands_status.py` | команда `!status` (embed, uptime) | 6 |
| `test_commands_stats.py` | команда `!stats` (серверы, каналы) | 4 | | `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 ## Запуск в 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 news
docker exec discord-bot python admin.py cat docker exec discord-bot python admin.py cat
docker exec discord-bot python admin.py morning 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 exec discord-bot python admin.py help
docker stop discord-bot # остановка бота docker stop discord-bot # остановка бота
``` ```
Команды `reload` и `trigger morning` доступны только через интерактивный терминал бота (требуют запущенного экземпляра бота).
Результат выводится в stdout терминала. Команды используют те же `utils`, что и Discord-команды. Результат выводится в stdout терминала. Команды используют те же `utils`, что и Discord-команды.
## API и внешние сервисы ## API и внешние сервисы

1
bot.py
View File

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

View File

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

View File

@ -11,6 +11,36 @@ import sys
import asyncio 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(): def print_help():
"""Показать список команд.""" """Показать список команд."""
print("\nАдмин-команды:") print("\nАдмин-команды:")
@ -20,6 +50,7 @@ def print_help():
("news", "Топ-5 статей и новостей AI с Habr"), ("news", "Топ-5 статей и новостей AI с Habr"),
("cat", "URL случайного котика"), ("cat", "URL случайного котика"),
("morning", "Утренний дайджест: погода + новости + котик"), ("morning", "Утренний дайджест: погода + новости + котик"),
("logs", "Последние строки лога (tail -20)"),
("help", "Показать этот список"), ("help", "Показать этот список"),
] ]
for name, desc in commands: for name, desc in commands:
@ -27,6 +58,8 @@ def print_help():
print("-" * 40) print("-" * 40)
print("\n status — через Discord: !status") print("\n status — через Discord: !status")
print(" stats — через Discord: !stats") print(" stats — через Discord: !stats")
print(" reload — через терминал бота (интерактив)")
print(" trigger morning — через терминал бота (интерактив)")
print(" stop — docker stop discord-bot\n") print(" stop — docker stop discord-bot\n")
@ -171,6 +204,7 @@ COMMANDS = {
"news": run_news, "news": run_news,
"cat": run_cat, "cat": run_cat,
"morning": run_morning, "morning": run_morning,
"logs": run_logs,
"help": lambda: print_help(), "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, "main")
assert hasattr(admin, "COMMANDS") assert hasattr(admin, "COMMANDS")
assert len(admin.COMMANDS) == 5 assert len(admin.COMMANDS) == 6
def test_commands_mapping(self): def test_commands_mapping(self):
"""Все команды зарегистрированы в маппинге.""" """Все команды зарегистрированы в маппинге."""
from console_commands.admin import COMMANDS from console_commands.admin import COMMANDS
expected = {"pogoda", "news", "cat", "morning", "help"} expected = {"pogoda", "news", "cat", "morning", "logs", "help"}
assert set(COMMANDS.keys()) == expected 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