From 91f34625a82920ee45c9e244859154a24b42b09a Mon Sep 17 00:00:00 2001 From: deadzilla Date: Fri, 12 Jun 2026 16:19:21 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=8B=20!status=20=D0=B8=20!stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены Discord-команды: - !status — пинг к gateway, uptime, статус (embed) - !stats — серверы, каналы, пользователи (embed) Добавлены консольные команды: - status — статус бота в терминале - stats — статистика серверов в терминале Тесты: - test_commands_status.py (6 тестов) - test_commands_stats.py (4 теста) - всего: 233 теста Обновления: - README.md — новые команды в таблицах и архитектуре - ISSUES.md — высокий приоритет закрыт - admin.py — hint на !status и !stats --- ISSUES.md | 4 +- README.md | 14 +++++- commands/__init__.py | 4 +- commands/stats.py | 26 ++++++++++ commands/status.py | 47 ++++++++++++++++++ console_commands/__init__.py | 8 ++- console_commands/admin.py | 4 +- console_commands/stats.py | 19 +++++++ console_commands/status.py | 38 ++++++++++++++ tests/test_commands_stats.py | 93 +++++++++++++++++++++++++++++++++++ tests/test_commands_status.py | 89 +++++++++++++++++++++++++++++++++ 11 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 commands/stats.py create mode 100644 commands/status.py create mode 100644 console_commands/stats.py create mode 100644 console_commands/status.py create mode 100644 tests/test_commands_stats.py create mode 100644 tests/test_commands_status.py diff --git a/ISSUES.md b/ISSUES.md index f53bd34..ee50a06 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -11,8 +11,8 @@ ### Высокий приоритет -- [ ] **`status`** — онлайн-статус бота, пинг к Discord gateway, uptime -- [ ] **`stats`** — кол-во серверов, каналов, пользователей +- [x] **`status`** — онлайн-статус бота, пинг к Discord gateway, uptime +- [x] **`stats`** — кол-во серверов, каналов, пользователей ### Средний приоритет diff --git a/README.md b/README.md index 664fdc1..325119d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ python bot.py | `!morning` | Погода + топ-5 статей + топ-5 новостей + котик (утренний дайджест) | | `!cat` | Случайный котик | | `!msg <текст>` | Повторить текст в чате | +| `!status` | Статус бота: пинг к Discord gateway, uptime | +| `!stats` | Количество серверов, каналов, пользователей | ## Команды терминала @@ -50,6 +52,8 @@ python bot.py | 3 | `morning` | Погода + топ-5 статей + топ-5 новостей + котик | | 4 | `cat` | Вывести URL случайного котика | | 5 | `help` | Показать список всех команд | +| 6 | `status` | Статус бота: пинг и uptime | +| 7 | `stats` | Количество серверов, каналов, пользователей | | 0 | `stop` | Остановка бота | > Номера команд генерируются автоматически из `ALL_CONSOLE_COMMANDS` в порядке определения в `console_commands/__init__.py`. @@ -65,6 +69,8 @@ commands/ # Discord команды (cogs) cat.py # !cat — случайный котик morning.py # !morning — утренний дайджест (обёртка над utils.morning_runner) help.py # !hp — список команд (автогенерация из bot.commands) + status.py # !status — статус бота: пинг, uptime + stats.py # !stats — серверы, каналы, пользователи console_commands/ # Консольные команды __init__.py # ALL_CONSOLE_COMMANDS — явные импорты admin.py # admin — CLI для docker exec (pogoda, news, cat, morning, help) @@ -74,6 +80,8 @@ console_commands/ # Консольные команды morning.py # morning — утренний дайджест в терминале cat.py # cat — вывод URL котика help.py # help — список всех команд + status.py # status — статус бота в терминале + stats.py # stats — статистика серверов в терминале 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() @@ -94,6 +102,8 @@ tests/ # pytest-тесты test_help_console.py # консольная help — проверка списка команд test_logger.py # setup_logging — уровни, обработчики, формат test_admin.py # admin.py — CLI-скрипт для docker exec + test_commands_status.py # команда !status — embed и uptime + test_commands_stats.py # команда !stats — подсчёт серверов и каналов ISSUES.md # Задачи и баг-трекер проекта pytest.ini # Конфигурация pytest (asyncio_mode = auto) Dockerfile # Сборка образа бота (Python 3.14-slim, healthcheck) @@ -136,8 +146,10 @@ python -m pytest tests/ -v | `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 | | `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 | | `test_admin.py` | `admin.py` — CLI для docker exec | 5 | +| `test_commands_status.py` | команда `!status` (embed, uptime) | 6 | +| `test_commands_stats.py` | команда `!stats` (серверы, каналы) | 4 | -**Итого: 223 теста.** +**Итого: 233 теста.** ## Запуск в Docker diff --git a/commands/__init__.py b/commands/__init__.py index f3cbbb7..390c4ec 100644 --- a/commands/__init__.py +++ b/commands/__init__.py @@ -3,5 +3,7 @@ from .news import News from .cat import Cat from .morning import Morning from .help import Help +from .status import Status +from .stats import Stats -ALL_COMMANDS = [Pg, News, Cat, Morning, Help] +ALL_COMMANDS = [Pg, News, Cat, Morning, Help, Status, Stats] diff --git a/commands/stats.py b/commands/stats.py new file mode 100644 index 0000000..5a0a3d5 --- /dev/null +++ b/commands/stats.py @@ -0,0 +1,26 @@ +import discord +from discord.ext import commands + + +class Stats(commands.Cog): + """Команда !stats — статистика серверов""" + + @commands.command(name="stats") + async def stats(self, ctx): + """Количество серверов, каналов и пользователей""" + guilds = ctx.bot.guilds + total_guilds = len(guilds) + total_channels = sum(guild.channels.__len__() for guild in guilds) + total_members = sum(guild.member_count or 0 for guild in guilds) + latency_ms = round(ctx.bot.latency * 1000, 1) + + embed = discord.Embed( + title="Статистика серверов", + colour=discord.Color.blue(), + ) + embed.add_field(name="Серверов", value=str(total_guilds), inline=True) + embed.add_field(name="Каналов", value=str(total_channels), inline=True) + embed.add_field(name="Пользователей", value=str(total_members), inline=True) + embed.add_field(name="Пинг", value=f"{latency_ms} мс", inline=True) + + await ctx.send(embed=embed) diff --git a/commands/status.py b/commands/status.py new file mode 100644 index 0000000..c93709d --- /dev/null +++ b/commands/status.py @@ -0,0 +1,47 @@ +import time + +import discord +from discord.ext import commands + + +class Status(commands.Cog): + """Команда !status — статус бота, пинг, uptime""" + + def __init__(self): + self._start_time = time.time() + + @commands.command(name="status") + async def status(self, ctx): + """Статус бота: пинг к Discord gateway и время работы""" + latency_ms = round(ctx.bot.latency * 1000, 1) + uptime_seconds = time.time() - self._start_time + uptime_str = self._format_uptime(uptime_seconds) + + embed = discord.Embed( + title="Статус бота", + colour=discord.Color.green(), + ) + embed.add_field(name="Пинг", value=f"{latency_ms} мс", inline=True) + embed.add_field(name="Uptime", value=uptime_str, inline=True) + embed.add_field(name="Статус", value="Online", inline=True) + + await ctx.send(embed=embed) + + @staticmethod + def _format_uptime(total_seconds: float) -> str: + """Форматировать секунды в человекочитаемый вид.""" + days = int(total_seconds // 86400) + hours = int((total_seconds % 86400) // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + + parts = [] + if days: + parts.append(f"{days}д") + if hours: + parts.append(f"{hours}ч") + if minutes: + parts.append(f"{minutes}м") + parts.append(f"{seconds}с") + + return " ".join(parts) diff --git a/console_commands/__init__.py b/console_commands/__init__.py index 3173501..dd1ce44 100644 --- a/console_commands/__init__.py +++ b/console_commands/__init__.py @@ -3,7 +3,9 @@ from .news import news from .cat import cat from .pogoda import pogoda from .morning import morning -from .help import help # добавлено +from .help import help +from .status import status +from .stats import stats ALL_CONSOLE_COMMANDS = { "stop": stop, @@ -11,5 +13,7 @@ ALL_CONSOLE_COMMANDS = { "cat": cat, "pogoda": pogoda, "morning": morning, - "help": help, # добавлено + "help": help, + "status": status, + "stats": stats, } diff --git a/console_commands/admin.py b/console_commands/admin.py index d2ecf59..e2b7314 100644 --- a/console_commands/admin.py +++ b/console_commands/admin.py @@ -25,7 +25,9 @@ def print_help(): for name, desc in commands: print(f" {name:<12} — {desc}") print("-" * 40) - print("\nОстановка бота: docker stop discord-bot\n") + print("\n status — через Discord: !status") + print(" stats — через Discord: !stats") + print(" stop — docker stop discord-bot\n") async def run_pogoda(): diff --git a/console_commands/stats.py b/console_commands/stats.py new file mode 100644 index 0000000..10a47b8 --- /dev/null +++ b/console_commands/stats.py @@ -0,0 +1,19 @@ +def stats(stop_event, bot): + """Показать статистику серверов: серверы, каналы, пользователи""" + if stop_event.is_set(): + return None + + guilds = bot.guilds + total_guilds = len(guilds) + total_channels = sum(len(guild.channels) for guild in guilds) + total_members = sum(guild.member_count or 0 for guild in guilds) + latency_ms = round(bot.latency * 1000, 1) + + print("\n" + "=" * 40) + print("Статистика серверов") + print("=" * 40) + print(f" Серверов: {total_guilds}") + print(f" Каналов: {total_channels}") + print(f" Пользователей: {total_members}") + print(f" Пинг: {latency_ms} мс") + print("=" * 40) diff --git a/console_commands/status.py b/console_commands/status.py new file mode 100644 index 0000000..49a5b2d --- /dev/null +++ b/console_commands/status.py @@ -0,0 +1,38 @@ +import time + + +def status(stop_event, bot): + """Показать статус бота: пинг и время работы""" + if stop_event.is_set(): + return None + + latency_ms = round(bot.latency * 1000, 1) + uptime_seconds = time.time() - getattr(bot, "_start_time", time.time()) + uptime_str = _format_uptime(uptime_seconds) + + print("\n" + "=" * 40) + print("Статус бота") + print("=" * 40) + print(f" Пинг: {latency_ms} мс") + print(f" Uptime: {uptime_str}") + print(f" Статус: Online") + print("=" * 40) + + +def _format_uptime(total_seconds: float) -> str: + """Форматировать секунды в человекочитаемый вид.""" + days = int(total_seconds // 86400) + hours = int((total_seconds % 86400) // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + + parts = [] + if days: + parts.append(f"{days}д") + if hours: + parts.append(f"{hours}ч") + if minutes: + parts.append(f"{minutes}м") + parts.append(f"{seconds}с") + + return " ".join(parts) diff --git a/tests/test_commands_stats.py b/tests/test_commands_stats.py new file mode 100644 index 0000000..595941e --- /dev/null +++ b/tests/test_commands_stats.py @@ -0,0 +1,93 @@ +"""Тесты для команды !stats.""" +import pytest +from unittest.mock import AsyncMock, MagicMock + + +class TestStatsCommand: + """Тесты Discord-команды stats.""" + + def _make_mock_guild(self, channel_count=5, member_count=100): + """Создать mock-сервер.""" + guild = MagicMock() + guild.channels = [MagicMock() for _ in range(channel_count)] + guild.member_count = member_count + return guild + + async def test_stats_sends_embed(self): + """Команда stats отправляет embed-сообщение.""" + from commands.stats import Stats + + mock_ctx = MagicMock() + mock_ctx.bot.latency = 0.035 + mock_ctx.bot.guilds = [] + mock_ctx.send = AsyncMock(return_value=None) + + cog = Stats() + await cog.stats(cog, mock_ctx) + + mock_ctx.send.assert_awaited_once() + call_args = mock_ctx.send.call_args + embed = call_args[1]["embed"] if call_args[1] else call_args[0][0] + assert embed.title == "Статистика серверов" + + async def test_stats_correct_values(self): + """Значения серверов, каналов и пользователей считаются верно.""" + from commands.stats import Stats + + guild1 = self._make_mock_guild(channel_count=10, member_count=200) + guild2 = self._make_mock_guild(channel_count=5, member_count=50) + + mock_ctx = MagicMock() + mock_ctx.bot.latency = 0.035 + mock_ctx.bot.guilds = [guild1, guild2] + mock_ctx.send = AsyncMock(return_value=None) + + cog = Stats() + await cog.stats(cog, mock_ctx) + + call_args = mock_ctx.send.call_args + embed = call_args[1]["embed"] if call_args[1] else call_args[0][0] + + fields = {f.name: f.value for f in embed.fields} + assert fields["Серверов"] == "2" + assert fields["Каналов"] == "15" + assert fields["Пользователей"] == "250" + assert "35.0 мс" in fields["Пинг"] + + async def test_stats_empty_guilds(self): + """Пустой список серверов не вызывает ошибок.""" + from commands.stats import Stats + + mock_ctx = MagicMock() + mock_ctx.bot.latency = 0.050 + mock_ctx.bot.guilds = [] + mock_ctx.send = AsyncMock(return_value=None) + + cog = Stats() + await cog.stats(cog, mock_ctx) + + call_args = mock_ctx.send.call_args + embed = call_args[1]["embed"] if call_args[1] else call_args[0][0] + fields = {f.name: f.value for f in embed.fields} + assert fields["Серверов"] == "0" + assert fields["Каналов"] == "0" + assert fields["Пользователей"] == "0" + + async def test_stats_none_member_count(self): + """member_count=None не вызывает ошибок.""" + from commands.stats import Stats + + guild = self._make_mock_guild(channel_count=3, member_count=None) + + mock_ctx = MagicMock() + mock_ctx.bot.latency = 0.040 + mock_ctx.bot.guilds = [guild] + mock_ctx.send = AsyncMock(return_value=None) + + cog = Stats() + await cog.stats(cog, mock_ctx) + + call_args = mock_ctx.send.call_args + embed = call_args[1]["embed"] if call_args[1] else call_args[0][0] + fields = {f.name: f.value for f in embed.fields} + assert fields["Пользователей"] == "0" diff --git a/tests/test_commands_status.py b/tests/test_commands_status.py new file mode 100644 index 0000000..77da01a --- /dev/null +++ b/tests/test_commands_status.py @@ -0,0 +1,89 @@ +"""Тесты для команды !status.""" +import time + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + + +class TestStatusCommand: + """Тесты Discord-команды status.""" + + def _make_cog(self, start_time=None): + """Создать Status cog с моком времени.""" + from commands.status import Status + + cog = Status() + if start_time is not None: + cog._start_time = start_time + return cog + + async def test_status_sends_embed(self): + """Команда status отправляет embed-сообщение.""" + from commands.status import Status + + mock_ctx = MagicMock() + mock_ctx.bot.latency = 0.042 + mock_ctx.send = AsyncMock(return_value=None) + + cog = self._make_cog() + await cog.status(cog, mock_ctx) + + mock_ctx.send.assert_awaited_once() + call_args = mock_ctx.send.call_args + embed = call_args[1]["embed"] if call_args[1] else call_args[0][0] + assert embed.title == "Статус бота" + assert "42.0 мс" in embed.fields[0].value + + async def test_status_uptime_format(self): + """Uptime форматируется корректно.""" + from commands.status import Status + + mock_ctx = MagicMock() + mock_ctx.bot.latency = 0.050 + mock_ctx.send = AsyncMock(return_value=None) + + start = time.time() - 90061 # 1д 1ч 1м 1с + cog = self._make_cog(start_time=start) + await cog.status(cog, mock_ctx) + + call_args = mock_ctx.send.call_args + embed = call_args[1]["embed"] if call_args[1] else call_args[0][0] + uptime = embed.fields[1].value + assert "1д" in uptime + assert "1ч" in uptime + assert "1м" in uptime + assert "1с" in uptime + + +class TestFormatUptime: + """Тесты форматирования uptime.""" + + def test_zero_seconds(self): + from commands.status import Status + + result = Status._format_uptime(0) + assert result == "0с" + + def test_minutes_and_seconds(self): + from commands.status import Status + + result = Status._format_uptime(125) # 2м 5с + assert "2м" in result + assert "5с" in result + + def test_hours_minutes_seconds(self): + from commands.status import Status + + result = Status._format_uptime(3661) # 1ч 1м 1с + assert "1ч" in result + assert "1м" in result + assert "1с" in result + + def test_full_day(self): + from commands.status import Status + + result = Status._format_uptime(90061) # 1д 1ч 1м 1с + assert "1д" in result + assert "1ч" in result + assert "1м" in result + assert "1с" in result