feat: команды !status и !stats

Добавлены 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
This commit is contained in:
deadzilla 2026-06-12 16:19:21 +05:00
parent 12ed515cca
commit 91f34625a8
11 changed files with 339 additions and 7 deletions

View File

@ -11,8 +11,8 @@
### Высокий приоритет ### Высокий приоритет
- [ ] **`status`** — онлайн-статус бота, пинг к Discord gateway, uptime - [x] **`status`** — онлайн-статус бота, пинг к Discord gateway, uptime
- [ ] **`stats`** — кол-во серверов, каналов, пользователей - [x] **`stats`** — кол-во серверов, каналов, пользователей
### Средний приоритет ### Средний приоритет

View File

@ -40,6 +40,8 @@ python bot.py
| `!morning` | Погода + топ-5 статей + топ-5 новостей + котик (утренний дайджест) | | `!morning` | Погода + топ-5 статей + топ-5 новостей + котик (утренний дайджест) |
| `!cat` | Случайный котик | | `!cat` | Случайный котик |
| `!msg <текст>` | Повторить текст в чате | | `!msg <текст>` | Повторить текст в чате |
| `!status` | Статус бота: пинг к Discord gateway, uptime |
| `!stats` | Количество серверов, каналов, пользователей |
## Команды терминала ## Команды терминала
@ -50,6 +52,8 @@ python bot.py
| 3 | `morning` | Погода + топ-5 статей + топ-5 новостей + котик | | 3 | `morning` | Погода + топ-5 статей + топ-5 новостей + котик |
| 4 | `cat` | Вывести URL случайного котика | | 4 | `cat` | Вывести URL случайного котика |
| 5 | `help` | Показать список всех команд | | 5 | `help` | Показать список всех команд |
| 6 | `status` | Статус бота: пинг и uptime |
| 7 | `stats` | Количество серверов, каналов, пользователей |
| 0 | `stop` | Остановка бота | | 0 | `stop` | Остановка бота |
> Номера команд генерируются автоматически из `ALL_CONSOLE_COMMANDS` в порядке определения в `console_commands/__init__.py`. > Номера команд генерируются автоматически из `ALL_CONSOLE_COMMANDS` в порядке определения в `console_commands/__init__.py`.
@ -65,6 +69,8 @@ commands/ # Discord команды (cogs)
cat.py # !cat — случайный котик cat.py # !cat — случайный котик
morning.py # !morning — утренний дайджест (обёртка над utils.morning_runner) morning.py # !morning — утренний дайджест (обёртка над utils.morning_runner)
help.py # !hp — список команд (автогенерация из bot.commands) help.py # !hp — список команд (автогенерация из bot.commands)
status.py # !status — статус бота: пинг, uptime
stats.py # !stats — серверы, каналы, пользователи
console_commands/ # Консольные команды console_commands/ # Консольные команды
__init__.py # ALL_CONSOLE_COMMANDS — явные импорты __init__.py # ALL_CONSOLE_COMMANDS — явные импорты
admin.py # admin — CLI для docker exec (pogoda, news, cat, morning, help) admin.py # admin — CLI для docker exec (pogoda, news, cat, morning, help)
@ -74,6 +80,8 @@ console_commands/ # Консольные команды
morning.py # morning — утренний дайджест в терминале morning.py # morning — утренний дайджест в терминале
cat.py # cat — вывод URL котика cat.py # cat — вывод URL котика
help.py # help — список всех команд help.py # help — список всех команд
status.py # status — статус бота в терминале
stats.py # stats — статистика серверов в терминале
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()
@ -94,6 +102,8 @@ tests/ # pytest-тесты
test_help_console.py # консольная help — проверка списка команд test_help_console.py # консольная help — проверка списка команд
test_logger.py # setup_logging — уровни, обработчики, формат test_logger.py # setup_logging — уровни, обработчики, формат
test_admin.py # admin.py — CLI-скрипт для docker exec test_admin.py # admin.py — CLI-скрипт для docker exec
test_commands_status.py # команда !status — embed и uptime
test_commands_stats.py # команда !stats — подсчёт серверов и каналов
ISSUES.md # Задачи и баг-трекер проекта ISSUES.md # Задачи и баг-трекер проекта
pytest.ini # Конфигурация pytest (asyncio_mode = auto) pytest.ini # Конфигурация pytest (asyncio_mode = auto)
Dockerfile # Сборка образа бота (Python 3.14-slim, healthcheck) Dockerfile # Сборка образа бота (Python 3.14-slim, healthcheck)
@ -136,8 +146,10 @@ python -m pytest tests/ -v
| `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 | | `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 |
| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 | | `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 |
| `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_stats.py` | команда `!stats` (серверы, каналы) | 4 |
**Итого: 223 теста.** **Итого: 233 теста.**
## Запуск в Docker ## Запуск в Docker

View File

@ -3,5 +3,7 @@ from .news import News
from .cat import Cat from .cat import Cat
from .morning import Morning from .morning import Morning
from .help import Help 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]

26
commands/stats.py Normal file
View File

@ -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)

47
commands/status.py Normal file
View File

@ -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)

View File

@ -3,7 +3,9 @@ from .news import news
from .cat import cat from .cat import cat
from .pogoda import pogoda from .pogoda import pogoda
from .morning import morning from .morning import morning
from .help import help # добавлено from .help import help
from .status import status
from .stats import stats
ALL_CONSOLE_COMMANDS = { ALL_CONSOLE_COMMANDS = {
"stop": stop, "stop": stop,
@ -11,5 +13,7 @@ ALL_CONSOLE_COMMANDS = {
"cat": cat, "cat": cat,
"pogoda": pogoda, "pogoda": pogoda,
"morning": morning, "morning": morning,
"help": help, # добавлено "help": help,
"status": status,
"stats": stats,
} }

View File

@ -25,7 +25,9 @@ def print_help():
for name, desc in commands: for name, desc in commands:
print(f" {name:<12}{desc}") print(f" {name:<12}{desc}")
print("-" * 40) 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(): async def run_pogoda():

19
console_commands/stats.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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"

View File

@ -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 "" in uptime
assert "" in uptime
assert "" 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 "" 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 "" in result
assert "" 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 "" in result
assert "" in result
assert "" in result
assert "1с" in result