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
- [ ] **`stats`** — кол-во серверов, каналов, пользователей
- [x] **`status`** — онлайн-статус бота, пинг к Discord gateway, uptime
- [x] **`stats`** — кол-во серверов, каналов, пользователей
### Средний приоритет

View File

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

View File

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

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 .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,
}

View File

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

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