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:
parent
5c438b4c48
commit
c37baafacc
@ -16,9 +16,7 @@
|
||||
|
||||
### Средний приоритет
|
||||
|
||||
- [ ] **`reload`** — горячая перезагрузка cogs без остановки бота
|
||||
- [ ] **`logs`** — последние строки лога (tail -20)
|
||||
- [ ] **`trigger morning`** — ручной запуск morning-дайджеста в канал
|
||||
- [
|
||||
|
||||
### Низкий приоритет
|
||||
|
||||
|
||||
14
README.md
14
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 # остановка бота
|
||||
```
|
||||
|
||||
Команды `reload` и `trigger morning` доступны только через интерактивный терминал бота (требуют запущенного экземпляра бота).
|
||||
|
||||
Результат выводится в stdout терминала. Команды используют те же `utils`, что и Discord-команды.
|
||||
|
||||
## API и внешние сервисы
|
||||
|
||||
1
bot.py
1
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()
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -11,6 +11,36 @@ 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Админ-команды:")
|
||||
@ -20,6 +50,7 @@ def print_help():
|
||||
("news", "Топ-5 статей и новостей AI с Habr"),
|
||||
("cat", "URL случайного котика"),
|
||||
("morning", "Утренний дайджест: погода + новости + котик"),
|
||||
("logs", "Последние строки лога (tail -20)"),
|
||||
("help", "Показать этот список"),
|
||||
]
|
||||
for name, desc in commands:
|
||||
@ -27,6 +58,8 @@ def print_help():
|
||||
print("-" * 40)
|
||||
print("\n status — через Discord: !status")
|
||||
print(" stats — через Discord: !stats")
|
||||
print(" reload — через терминал бота (интерактив)")
|
||||
print(" trigger morning — через терминал бота (интерактив)")
|
||||
print(" stop — docker stop discord-bot\n")
|
||||
|
||||
|
||||
@ -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
28
console_commands/logs.py
Normal 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}")
|
||||
23
console_commands/reload.py
Normal file
23
console_commands/reload.py
Normal 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)}")
|
||||
22
console_commands/trigger_morning.py
Normal file
22
console_commands/trigger_morning.py
Normal 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Утренний дайджест запущен вручную.")
|
||||
@ -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
|
||||
|
||||
87
tests/test_console_logs.py
Normal file
87
tests/test_console_logs.py
Normal 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
|
||||
54
tests/test_console_reload.py
Normal file
54
tests/test_console_reload.py
Normal 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
|
||||
53
tests/test_console_trigger_morning.py
Normal file
53
tests/test_console_trigger_morning.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user