Уведомления в чат логов и ежечасный пинг

- единая отправка уведомлений через bot/services/alerts.py, ошибки не ломают обработку
- вход и выход участников и успешные очищения уходят в ALERT_CHAT_ID
- ежечасный пинг «бот живой» перенесён в heartbeat, дублирующий пинг из рассылок убран
- событие bot_alive в ClickHouse для графиков доступности
- служебные теги chat_title, version и uptime в реестре тегов
- версия 0.3.3
This commit is contained in:
MpakobecEx 2026-10-16 15:26:56 +05:00
parent 6ec8de88b4
commit 2031133538
15 changed files with 376 additions and 40 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

74
bot/services/alerts.py Normal file
View File

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

View File

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

View File

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

View File

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

13
common/texts/alerts.py Normal file
View File

@ -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} стучится в чат"

View File

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

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.3.2"
__version__ = "0.3.3"

20
tests/conftest.py Normal file
View File

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

179
tests/test_alerts.py Normal file
View File

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

View File

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