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:
parent
12ed515cca
commit
91f34625a8
@ -11,8 +11,8 @@
|
||||
|
||||
### Высокий приоритет
|
||||
|
||||
- [ ] **`status`** — онлайн-статус бота, пинг к Discord gateway, uptime
|
||||
- [ ] **`stats`** — кол-во серверов, каналов, пользователей
|
||||
- [x] **`status`** — онлайн-статус бота, пинг к Discord gateway, uptime
|
||||
- [x] **`stats`** — кол-во серверов, каналов, пользователей
|
||||
|
||||
### Средний приоритет
|
||||
|
||||
|
||||
14
README.md
14
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
|
||||
|
||||
|
||||
@ -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
26
commands/stats.py
Normal 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
47
commands/status.py
Normal 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)
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
19
console_commands/stats.py
Normal 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)
|
||||
38
console_commands/status.py
Normal file
38
console_commands/status.py
Normal 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)
|
||||
93
tests/test_commands_stats.py
Normal file
93
tests/test_commands_stats.py
Normal 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"
|
||||
89
tests/test_commands_status.py
Normal file
89
tests/test_commands_status.py
Normal 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 "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
|
||||
Loading…
x
Reference in New Issue
Block a user