From c37baafacc5718f66d4d16b529ed161c0975e3f7 Mon Sep 17 00:00:00 2001 From: deadzilla Date: Fri, 12 Jun 2026 18:03:47 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B?= =?UTF-8?q?=20logs,=20reload,=20trigger=20morning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 теста --- ISSUES.md | 4 +- README.md | 16 ++++- bot.py | 1 + console_commands/__init__.py | 6 ++ console_commands/admin.py | 50 ++++++++++++--- console_commands/logs.py | 28 +++++++++ console_commands/reload.py | 23 +++++++ console_commands/trigger_morning.py | 22 +++++++ tests/test_admin.py | 4 +- tests/test_console_logs.py | 87 +++++++++++++++++++++++++++ tests/test_console_reload.py | 54 +++++++++++++++++ tests/test_console_trigger_morning.py | 53 ++++++++++++++++ 12 files changed, 333 insertions(+), 15 deletions(-) create mode 100644 console_commands/logs.py create mode 100644 console_commands/reload.py create mode 100644 console_commands/trigger_morning.py create mode 100644 tests/test_console_logs.py create mode 100644 tests/test_console_reload.py create mode 100644 tests/test_console_trigger_morning.py diff --git a/ISSUES.md b/ISSUES.md index ee50a06..409c7df 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -16,9 +16,7 @@ ### Средний приоритет -- [ ] **`reload`** — горячая перезагрузка cogs без остановки бота -- [ ] **`logs`** — последние строки лога (tail -20) -- [ ] **`trigger morning`** — ручной запуск morning-дайджеста в канал +- [ ### Низкий приоритет diff --git a/README.md b/README.md index 325119d..be9c094 100644 --- a/README.md +++ b/README.md @@ -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 и внешние сервисы diff --git a/bot.py b/bot.py index c8ea204..a34ed07 100644 --- a/bot.py +++ b/bot.py @@ -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() diff --git a/console_commands/__init__.py b/console_commands/__init__.py index dd1ce44..10d88b2 100644 --- a/console_commands/__init__.py +++ b/console_commands/__init__.py @@ -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, } diff --git a/console_commands/admin.py b/console_commands/admin.py index e2b7314..ee87d30 100644 --- a/console_commands/admin.py +++ b/console_commands/admin.py @@ -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(), } diff --git a/console_commands/logs.py b/console_commands/logs.py new file mode 100644 index 0000000..818c5f0 --- /dev/null +++ b/console_commands/logs.py @@ -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}") diff --git a/console_commands/reload.py b/console_commands/reload.py new file mode 100644 index 0000000..b828ea1 --- /dev/null +++ b/console_commands/reload.py @@ -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)}") diff --git a/console_commands/trigger_morning.py b/console_commands/trigger_morning.py new file mode 100644 index 0000000..d6a596f --- /dev/null +++ b/console_commands/trigger_morning.py @@ -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Утренний дайджест запущен вручную.") diff --git a/tests/test_admin.py b/tests/test_admin.py index 5033d25..65bd62a 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -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 diff --git a/tests/test_console_logs.py b/tests/test_console_logs.py new file mode 100644 index 0000000..5643894 --- /dev/null +++ b/tests/test_console_logs.py @@ -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 diff --git a/tests/test_console_reload.py b/tests/test_console_reload.py new file mode 100644 index 0000000..ec49cd8 --- /dev/null +++ b/tests/test_console_reload.py @@ -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 diff --git a/tests/test_console_trigger_morning.py b/tests/test_console_trigger_morning.py new file mode 100644 index 0000000..7d821fa --- /dev/null +++ b/tests/test_console_trigger_morning.py @@ -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