diff --git a/README.md b/README.md index b0d1156..f4b8c6f 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ pytest ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.2`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.3`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/bot/__main__.py b/bot/__main__.py index 24011e6..4659a2f 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -31,7 +31,7 @@ async def main() -> None: dp = create_dispatcher() notifications_task = asyncio.create_task(run_notifications(bot)) - heartbeat_task = asyncio.create_task(run_heartbeat()) + heartbeat_task = asyncio.create_task(run_heartbeat(bot)) try: await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) diff --git a/bot/handlers/economy.py b/bot/handlers/economy.py index 41ee67e..169c4cd 100644 --- a/bot/handlers/economy.py +++ b/bot/handlers/economy.py @@ -9,7 +9,7 @@ from aiogram import Router from aiogram.filters import Command from aiogram.types import Message -from bot.services import access +from bot.services import access, alerts from bot.services import economy as economy_service from common.config import get_settings from common.texts import economy, system @@ -37,6 +37,12 @@ async def purification_handler(message: Message) -> None: else: context = {"money_add": result.money_add, "exp_add": result.exp_add} await message.answer(render(economy.MSG_PURIFICATION, context)) + await alerts.notify_purification( + message.bot, + username=message.from_user.username, + user_id=message.from_user.id, + result=result, + ) @router.message(Command("яички", ignore_case=True)) diff --git a/bot/handlers/member_events.py b/bot/handlers/member_events.py index 82cc8cf..3357c06 100644 --- a/bot/handlers/member_events.py +++ b/bot/handlers/member_events.py @@ -9,6 +9,7 @@ from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdated from aiogram.types import ChatMemberUpdated, FSInputFile from aiogram.utils.keyboard import InlineKeyboardBuilder +from bot.services import alerts from bot.services import users as users_service from common.analytics import EventType, record from common.config import get_settings @@ -40,6 +41,12 @@ async def welcome_handler(event: ChatMemberUpdated) -> None: return await users_service.register_from_event(event) + await alerts.notify_join( + event.bot, + username=user.username, + user_id=user.id, + chat_title=event.chat.title or "", + ) builder = InlineKeyboardBuilder() builder.button(text=welcome.MSG_WELCOME_BUTTON_CAPTION, url=welcome.MSG_WELCOME_BUTTON_URL) @@ -60,7 +67,6 @@ async def welcome_handler(event: ChatMemberUpdated) -> None: @router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION)) async def goodbye_handler(event: ChatMemberUpdated) -> None: """Пользователь вышел из чата — удалить запись.""" - settings = get_settings() user = event.from_user if user is None or user.is_bot: return @@ -75,12 +81,9 @@ async def goodbye_handler(event: ChatMemberUpdated) -> None: await users_service.delete_user(user.id) logger.info("Удалён пользователь %s после выхода из чата", user.id) - if not settings.alert_chat_id: - return - try: - await event.bot.send_message( - chat_id=settings.alert_chat_id, - text=f"[goodbye] {user.username or user.id} покинул чат {event.chat.title}", - ) - except Exception: - logger.exception("Не удалось отправить уведомление о выходе пользователя") + await alerts.notify_leave( + event.bot, + username=user.username, + user_id=user.id, + chat_title=event.chat.title or "", + ) diff --git a/bot/services/access.py b/bot/services/access.py index f261620..b29d31c 100644 --- a/bot/services/access.py +++ b/bot/services/access.py @@ -9,9 +9,12 @@ from aiogram import Bot from aiogram.enums import ChatMemberStatus from aiogram.types import Message +from bot.services import alerts from bot.services import users as users_service from common.analytics import EventType, record, remember_user from common.config import get_settings +from common.texts import alerts as alerts_text +from common.texts.render import render logger = logging.getLogger(__name__) @@ -67,20 +70,8 @@ async def check_user(message: Message) -> bool: async def _notify_denied(message: Message) -> None: """Сообщить в чат логов (если задан) о попытке доступа извне.""" - settings = get_settings() - if not settings.alert_chat_id: + user = message.from_user + if user is None: return - - username = message.from_user.username if message.from_user else None - try: - await message.bot.send_message( - chat_id=settings.alert_chat_id, - text=f"[check_user]\nСтучится в чат {username}!", - ) - await message.bot.forward_message( - chat_id=settings.alert_chat_id, - from_chat_id=message.chat.id, - message_id=message.message_id, - ) - except Exception: # уведомление не должно ломать обработку - logger.exception("Не удалось отправить уведомление о запрете доступа") + text = render(alerts_text.MSG_ACCESS_DENIED, {"user": alerts.user_tag(user.username, user.id)}) + await alerts.notify_alert(message.bot, text, forward=message) diff --git a/bot/services/alerts.py b/bot/services/alerts.py new file mode 100644 index 0000000..2e45f5c --- /dev/null +++ b/bot/services/alerts.py @@ -0,0 +1,74 @@ +"""Уведомления в чат логов (``ALERT_CHAT_ID``). + +Единая точка отправки служебных сообщений бота: вход/выход участников, +успешные очищения, пинг «бот живой». Уведомления **необязательные**: +если чат не задан (``ALERT_CHAT_ID=0``) или отправка не удалась — это только +запись в лог, обработка не прерывается. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from common.config import get_settings +from common.texts import alerts as alerts_text +from common.texts.render import render + +if TYPE_CHECKING: + from aiogram import Bot + from aiogram.types import Message + + from common.models import PurificationResult + +logger = logging.getLogger(__name__) + + +def user_tag(username: str | None, user_id: int) -> str: + """Представление пользователя для служебного сообщения (``@username`` или id).""" + return f"@{username}" if username else str(user_id) + + +async def notify_alert(bot: Bot, text: str, *, forward: Message | None = None) -> bool: + """Отправить текст в чат логов (при необходимости — с пересылкой сообщения). + + Возвращает ``False``, если чат логов не задан или отправка не удалась. + """ + settings = get_settings() + if not settings.alert_chat_id: + return False + + try: + await bot.send_message(chat_id=settings.alert_chat_id, text=text) + if forward is not None: + await bot.forward_message( + chat_id=settings.alert_chat_id, + from_chat_id=forward.chat.id, + message_id=forward.message_id, + ) + except Exception: # уведомление не должно ломать обработку + logger.exception("Не удалось отправить уведомление в чат логов") + return False + return True + + +async def notify_join(bot: Bot, *, username: str | None, user_id: int, chat_title: str) -> bool: + """Уведомление о входе участника в чат.""" + context = {"username": user_tag(username, user_id), "chat_title": chat_title} + return await notify_alert(bot, render(alerts_text.MSG_JOIN, context)) + + +async def notify_leave(bot: Bot, *, username: str | None, user_id: int, chat_title: str) -> bool: + """Уведомление о выходе участника из чата.""" + context = {"username": user_tag(username, user_id), "chat_title": chat_title} + return await notify_alert(bot, render(alerts_text.MSG_LEAVE, context)) + + +async def notify_purification(bot: Bot, *, username: str | None, user_id: int, result: PurificationResult) -> bool: + """Уведомление об успешном очищении.""" + context = { + "username": user_tag(username, user_id), + "exp_add": result.exp_add, + "money_add": result.money_add, + } + return await notify_alert(bot, render(alerts_text.MSG_PURIFICATION, context)) diff --git a/bot/services/heartbeat.py b/bot/services/heartbeat.py index b198488..b621d4f 100644 --- a/bot/services/heartbeat.py +++ b/bot/services/heartbeat.py @@ -1,28 +1,72 @@ -"""Heartbeat для Docker healthcheck. +"""Heartbeat для Docker healthcheck и ежечасный пинг «бот живой». Бот периодически обновляет mtime файла; healthcheck считает контейнер живым, если файл свежий. Если event loop завис — heartbeat перестаёт обновляться. + +Раз в час в чат логов (``ALERT_CHAT_ID``) уходит короткое сообщение о работе бота, +и в ClickHouse пишется событие ``bot_alive``. """ from __future__ import annotations import asyncio import logging +from datetime import datetime from pathlib import Path +from typing import TYPE_CHECKING +from bot.services.alerts import notify_alert +from bot.services.runtime import uptime_human, uptime_seconds +from common import __version__ +from common.analytics import EventType, record from common.config import get_settings +from common.texts import alerts as alerts_text +from common.texts.render import render + +if TYPE_CHECKING: + from aiogram import Bot logger = logging.getLogger(__name__) +#: Как часто обновлять heartbeat-файл. HEARTBEAT_INTERVAL = 30 +#: Час (в таймзоне бота), за который пинг уже отправлен. +_last_ping_hour: datetime | None = None -async def run_heartbeat() -> None: - """Обновлять файл-heartbeat каждые ``HEARTBEAT_INTERVAL`` секунд.""" + +async def ping_hourly(bot: Bot, now: datetime | None = None) -> bool: + """Записать событие ``bot_alive`` и отправить пинг в чат логов раз в час. + + Возвращает ``True``, если в этом часу пинг отправлен (или предпринята попытка). + Повторные вызовы в течение того же часа ничего не делают. + """ + global _last_ping_hour + + settings = get_settings() + current_hour = (now or datetime.now(settings.tzinfo)).replace(minute=0, second=0, microsecond=0) + if current_hour == _last_ping_hour: + return False + + _last_ping_hour = current_hour + record( + EventType.BOT_ALIVE, + payload={"app_env": settings.app_env, "uptime_seconds": uptime_seconds()}, + ) + text = render( + alerts_text.MSG_ALIVE, + {"version": __version__, "uptime": uptime_human()}, + ) + return await notify_alert(bot, text) + + +async def run_heartbeat(bot: Bot) -> None: + """Обновлять heartbeat-файл каждые ``HEARTBEAT_INTERVAL`` секунд и пинговать раз в час.""" path = Path(get_settings().heartbeat_path) while True: try: path.touch() except OSError: logger.exception("Не удалось обновить heartbeat-файл %s", path) + await ping_hourly(bot) await asyncio.sleep(HEARTBEAT_INTERVAL) diff --git a/bot/services/notifications.py b/bot/services/notifications.py index 3dd068d..1645410 100644 --- a/bot/services/notifications.py +++ b/bot/services/notifications.py @@ -61,11 +61,6 @@ async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> N async def _tick(bot: Bot, now: datetime) -> None: - settings = get_settings() - if now.minute == 0 and settings.alert_chat_id: - # Пинг «жив» в чат логов (если задан). - await bot.send_message(chat_id=settings.alert_chat_id, text="Я ЖЫФ!") - for schedule in SCHEDULES: if schedule.matches(now): subscribers = await users_service.list_subscribers() diff --git a/bot/services/runtime.py b/bot/services/runtime.py index 6aeaec7..1cf1547 100644 --- a/bot/services/runtime.py +++ b/bot/services/runtime.py @@ -17,4 +17,11 @@ def uptime_seconds() -> int: return int(time.monotonic() - STARTED_AT) -__all__ = ["STARTED_AT", "uptime_seconds"] +def uptime_human() -> str: + """Аптайм в виде ``3ч 05м`` или ``12м`` (для уведомлений).""" + hours, remainder = divmod(uptime_seconds(), 3600) + minutes = remainder // 60 + return f"{hours}ч {minutes:02d}м" if hours else f"{minutes}м" + + +__all__ = ["STARTED_AT", "uptime_human", "uptime_seconds"] diff --git a/common/texts/alerts.py b/common/texts/alerts.py new file mode 100644 index 0000000..3448990 --- /dev/null +++ b/common/texts/alerts.py @@ -0,0 +1,13 @@ +"""Тексты уведомлений в чат логов (``ALERT_CHAT_ID``). + +Служебные сообщения для админов (не для участников), поэтому в ``TEMPLATES`` +(редактируемые шаблоны Блока 4) они не входят. +""" + +from __future__ import annotations + +MSG_JOIN = "[вход] {username} вошёл в чат «{chat_title}»" +MSG_LEAVE = "[выход] {username} покинул чат «{chat_title}»" +MSG_PURIFICATION = "[очищение] {username} — опыт +{exp_add}, монеты +{money_add}" +MSG_ALIVE = "[бот живой] версия {version}, аптайм {uptime}" +MSG_ACCESS_DENIED = "[доступ] {username} стучится в чат" diff --git a/common/texts/render.py b/common/texts/render.py index 5855ef4..a02cc0e 100644 --- a/common/texts/render.py +++ b/common/texts/render.py @@ -42,6 +42,10 @@ TAG_REGISTRY: dict[str, str] = { "users_list_count": "Количество игроков в списке", "tops": "Топ игроков по сообщениям", "promo": "Промокод доната", + # --- Служебные теги уведомлений в чат логов (ALERT_CHAT_ID) --- + "chat_title": "Название чата", + "version": "Версия бота", + "uptime": "Время работы бота", } SUPPORTED_TAGS: frozenset[str] = frozenset(TAG_REGISTRY) diff --git a/common/version.py b/common/version.py index 78c8ed8..119ace1 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.3.2" +__version__ = "0.3.3" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a6d9db6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +"""Общие фикстуры тестов.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from common.config import Settings + + +@pytest.fixture +def settings_factory() -> Callable[..., Settings]: + """Фабрика ``Settings`` с обязательным ``BOT_TOKEN`` и без чтения ``.env``.""" + + def _factory(**overrides: Any) -> Settings: + return Settings(_env_file=None, bot_token="1:a", **overrides) + + return _factory diff --git a/tests/test_alerts.py b/tests/test_alerts.py new file mode 100644 index 0000000..332a45d --- /dev/null +++ b/tests/test_alerts.py @@ -0,0 +1,179 @@ +"""Тесты уведомлений в чат логов и ежечасного пинга «бот живой».""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import Any + +import pytest + +from bot.services import alerts, heartbeat +from common.analytics import EventType, close_analytics, init_analytics +from common.config import Settings +from common.models import PurificationResult + + +class FakeBot: + """Заглушка aiogram.Bot: записывает отправленные и пересланные сообщения.""" + + def __init__(self, *, fail: bool = False) -> None: + self.messages: list[tuple[int, str]] = [] + self.forwarded: list[tuple[int, int, int]] = [] + self._fail = fail + + async def send_message(self, chat_id: int, text: str) -> None: + if self._fail: + raise RuntimeError("Telegram недоступен") + self.messages.append((chat_id, text)) + + async def forward_message(self, chat_id: int, from_chat_id: int, message_id: int) -> None: + if self._fail: + raise RuntimeError("Telegram недоступен") + self.forwarded.append((chat_id, from_chat_id, message_id)) + + +class FakeClickHouse: + """Заглушка клиента ClickHouse (см. тесты аналитики).""" + + def __init__(self) -> None: + self.rows: list[list[Any]] = [] + + async def insert(self, table: str, data: list[list[Any]], column_names: list[str]) -> None: + self.rows.extend(data) + + async def close(self) -> None: + pass + + +@pytest.fixture(autouse=True) +async def _clean_singleton(): + await close_analytics() + yield + await close_analytics() + + +@pytest.fixture(autouse=True) +def _reset_ping_hour(monkeypatch: pytest.MonkeyPatch) -> None: + """Пинг не должен «протекать» между тестами.""" + monkeypatch.setattr(heartbeat, "_last_ping_hour", None) + + +def _patch_settings(monkeypatch: pytest.MonkeyPatch, settings: Settings) -> Settings: + """Подменить настройки во всех модулях, которые их читают.""" + monkeypatch.setattr("bot.services.alerts.get_settings", lambda: settings) + monkeypatch.setattr("bot.services.heartbeat.get_settings", lambda: settings) + return settings + + +# --- отправка в чат логов --- + + +async def test_notify_alert_skips_when_chat_disabled( + settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_settings(monkeypatch, settings_factory(alert_chat_id=0)) + bot = FakeBot() + + assert await alerts.notify_alert(bot, "текст") is False + assert bot.messages == [] + + +async def test_notify_alert_sends_to_alert_chat( + settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_settings(monkeypatch, settings_factory(alert_chat_id=-100)) + bot = FakeBot() + + assert await alerts.notify_alert(bot, "текст") is True + assert bot.messages == [(-100, "текст")] + + +async def test_notify_alert_failure_is_logged( + settings_factory: Callable[..., Settings], + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + _patch_settings(monkeypatch, settings_factory(alert_chat_id=-100)) + + with caplog.at_level(logging.ERROR): + assert await alerts.notify_alert(FakeBot(fail=True), "текст") is False + + assert any("не удалось отправить уведомление" in message.lower() for message in caplog.messages) + + +async def test_notify_alert_forwards_message( + settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_settings(monkeypatch, settings_factory(alert_chat_id=-100)) + bot = FakeBot() + message = SimpleNamespace(chat=SimpleNamespace(id=555), message_id=42) + + assert await alerts.notify_alert(bot, "текст", forward=message) is True + assert bot.forwarded == [(-100, 555, 42)] + + +async def test_notify_join_and_leave_text( + settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_settings(monkeypatch, settings_factory(alert_chat_id=-100)) + bot = FakeBot() + + await alerts.notify_join(bot, username="vasya", user_id=7, chat_title="Клан") + await alerts.notify_leave(bot, username=None, user_id=7, chat_title="Клан") + + assert bot.messages[0] == (-100, "[вход] @vasya вошёл в чат «Клан»") + assert bot.messages[1] == (-100, "[выход] 7 покинул чат «Клан»") + + +async def test_notify_purification_text( + settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_settings(monkeypatch, settings_factory(alert_chat_id=-100)) + bot = FakeBot() + + result = PurificationResult(success=True, exp_add=3, money_add=41) + await alerts.notify_purification(bot, username="vasya", user_id=7, result=result) + + assert bot.messages == [(-100, "[очищение] @vasya — опыт +3, монеты +41")] + + +# --- ежечасный пинг «бот живой» --- + + +async def test_ping_hourly_sends_once_per_hour( + settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch +) -> None: + settings = _patch_settings(monkeypatch, settings_factory(alert_chat_id=-100, app_env="prod")) + fake = FakeClickHouse() + await init_analytics(settings, client=fake) + bot = FakeBot() + + assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 0, tzinfo=UTC)) is True + assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 30, tzinfo=UTC)) is False + assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 11, 5, tzinfo=UTC)) is True + + await close_analytics() + + assert len(bot.messages) == 2 + assert "бот живой" in bot.messages[0][1] + assert [row[1] for row in fake.rows] == [EventType.BOT_ALIVE, EventType.BOT_ALIVE] + + +async def test_ping_hourly_records_event_without_alert_chat( + settings_factory: Callable[..., Settings], monkeypatch: pytest.MonkeyPatch +) -> None: + settings = _patch_settings(monkeypatch, settings_factory(alert_chat_id=0)) + fake = FakeClickHouse() + await init_analytics(settings, client=fake) + bot = FakeBot() + + assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 0, tzinfo=UTC)) is False + assert await heartbeat.ping_hourly(bot, now=datetime(2026, 1, 1, 10, 45, tzinfo=UTC)) is False + + await close_analytics() + + assert bot.messages == [] + assert [row[1] for row in fake.rows] == [EventType.BOT_ALIVE] diff --git a/tests/test_version.py b/tests/test_version.py index 35ab121..4fa3475 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.3.2" + assert __version__ == "0.3.3"