Уведомления в чат логов и ежечасный пинг
- единая отправка уведомлений через bot/services/alerts.py, ошибки не ломают обработку - вход и выход участников и успешные очищения уходят в ALERT_CHAT_ID - ежечасный пинг «бот живой» перенесён в heartbeat, дублирующий пинг из рассылок убран - событие bot_alive в ClickHouse для графиков доступности - служебные теги chat_title, version и uptime в реестре тегов - версия 0.3.3
This commit is contained in:
parent
6ec8de88b4
commit
2031133538
|
|
@ -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`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ async def main() -> None:
|
||||||
dp = create_dispatcher()
|
dp = create_dispatcher()
|
||||||
|
|
||||||
notifications_task = asyncio.create_task(run_notifications(bot))
|
notifications_task = asyncio.create_task(run_notifications(bot))
|
||||||
heartbeat_task = asyncio.create_task(run_heartbeat())
|
heartbeat_task = asyncio.create_task(run_heartbeat(bot))
|
||||||
try:
|
try:
|
||||||
await bot.delete_webhook(drop_pending_updates=True)
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from aiogram import Router
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import Message
|
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 bot.services import economy as economy_service
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
from common.texts import economy, system
|
from common.texts import economy, system
|
||||||
|
|
@ -37,6 +37,12 @@ async def purification_handler(message: Message) -> None:
|
||||||
else:
|
else:
|
||||||
context = {"money_add": result.money_add, "exp_add": result.exp_add}
|
context = {"money_add": result.money_add, "exp_add": result.exp_add}
|
||||||
await message.answer(render(economy.MSG_PURIFICATION, context))
|
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))
|
@router.message(Command("яички", ignore_case=True))
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdated
|
||||||
from aiogram.types import ChatMemberUpdated, FSInputFile
|
from aiogram.types import ChatMemberUpdated, FSInputFile
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from bot.services import alerts
|
||||||
from bot.services import users as users_service
|
from bot.services import users as users_service
|
||||||
from common.analytics import EventType, record
|
from common.analytics import EventType, record
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
|
|
@ -40,6 +41,12 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
await users_service.register_from_event(event)
|
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 = InlineKeyboardBuilder()
|
||||||
builder.button(text=welcome.MSG_WELCOME_BUTTON_CAPTION, url=welcome.MSG_WELCOME_BUTTON_URL)
|
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))
|
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
|
||||||
async def goodbye_handler(event: ChatMemberUpdated) -> None:
|
async def goodbye_handler(event: ChatMemberUpdated) -> None:
|
||||||
"""Пользователь вышел из чата — удалить запись."""
|
"""Пользователь вышел из чата — удалить запись."""
|
||||||
settings = get_settings()
|
|
||||||
user = event.from_user
|
user = event.from_user
|
||||||
if user is None or user.is_bot:
|
if user is None or user.is_bot:
|
||||||
return
|
return
|
||||||
|
|
@ -75,12 +81,9 @@ async def goodbye_handler(event: ChatMemberUpdated) -> None:
|
||||||
await users_service.delete_user(user.id)
|
await users_service.delete_user(user.id)
|
||||||
logger.info("Удалён пользователь %s после выхода из чата", user.id)
|
logger.info("Удалён пользователь %s после выхода из чата", user.id)
|
||||||
|
|
||||||
if not settings.alert_chat_id:
|
await alerts.notify_leave(
|
||||||
return
|
event.bot,
|
||||||
try:
|
username=user.username,
|
||||||
await event.bot.send_message(
|
user_id=user.id,
|
||||||
chat_id=settings.alert_chat_id,
|
chat_title=event.chat.title or "",
|
||||||
text=f"[goodbye] {user.username or user.id} покинул чат {event.chat.title}",
|
)
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Не удалось отправить уведомление о выходе пользователя")
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,12 @@ from aiogram import Bot
|
||||||
from aiogram.enums import ChatMemberStatus
|
from aiogram.enums import ChatMemberStatus
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from bot.services import alerts
|
||||||
from bot.services import users as users_service
|
from bot.services import users as users_service
|
||||||
from common.analytics import EventType, record, remember_user
|
from common.analytics import EventType, record, remember_user
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
|
from common.texts import alerts as alerts_text
|
||||||
|
from common.texts.render import render
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -67,20 +70,8 @@ async def check_user(message: Message) -> bool:
|
||||||
|
|
||||||
async def _notify_denied(message: Message) -> None:
|
async def _notify_denied(message: Message) -> None:
|
||||||
"""Сообщить в чат логов (если задан) о попытке доступа извне."""
|
"""Сообщить в чат логов (если задан) о попытке доступа извне."""
|
||||||
settings = get_settings()
|
user = message.from_user
|
||||||
if not settings.alert_chat_id:
|
if user is None:
|
||||||
return
|
return
|
||||||
|
text = render(alerts_text.MSG_ACCESS_DENIED, {"user": alerts.user_tag(user.username, user.id)})
|
||||||
username = message.from_user.username if message.from_user else None
|
await alerts.notify_alert(message.bot, text, forward=message)
|
||||||
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("Не удалось отправить уведомление о запрете доступа")
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -1,28 +1,72 @@
|
||||||
"""Heartbeat для Docker healthcheck.
|
"""Heartbeat для Docker healthcheck и ежечасный пинг «бот живой».
|
||||||
|
|
||||||
Бот периодически обновляет mtime файла; healthcheck считает контейнер живым,
|
Бот периодически обновляет mtime файла; healthcheck считает контейнер живым,
|
||||||
если файл свежий. Если event loop завис — heartbeat перестаёт обновляться.
|
если файл свежий. Если event loop завис — heartbeat перестаёт обновляться.
|
||||||
|
|
||||||
|
Раз в час в чат логов (``ALERT_CHAT_ID``) уходит короткое сообщение о работе бота,
|
||||||
|
и в ClickHouse пишется событие ``bot_alive``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Как часто обновлять heartbeat-файл.
|
||||||
HEARTBEAT_INTERVAL = 30
|
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)
|
path = Path(get_settings().heartbeat_path)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
path.touch()
|
path.touch()
|
||||||
except OSError:
|
except OSError:
|
||||||
logger.exception("Не удалось обновить heartbeat-файл %s", path)
|
logger.exception("Не удалось обновить heartbeat-файл %s", path)
|
||||||
|
await ping_hourly(bot)
|
||||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,6 @@ async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> N
|
||||||
|
|
||||||
|
|
||||||
async def _tick(bot: Bot, now: datetime) -> None:
|
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:
|
for schedule in SCHEDULES:
|
||||||
if schedule.matches(now):
|
if schedule.matches(now):
|
||||||
subscribers = await users_service.list_subscribers()
|
subscribers = await users_service.list_subscribers()
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,11 @@ def uptime_seconds() -> int:
|
||||||
return int(time.monotonic() - STARTED_AT)
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -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} стучится в чат"
|
||||||
|
|
@ -42,6 +42,10 @@ TAG_REGISTRY: dict[str, str] = {
|
||||||
"users_list_count": "Количество игроков в списке",
|
"users_list_count": "Количество игроков в списке",
|
||||||
"tops": "Топ игроков по сообщениям",
|
"tops": "Топ игроков по сообщениям",
|
||||||
"promo": "Промокод доната",
|
"promo": "Промокод доната",
|
||||||
|
# --- Служебные теги уведомлений в чат логов (ALERT_CHAT_ID) ---
|
||||||
|
"chat_title": "Название чата",
|
||||||
|
"version": "Версия бота",
|
||||||
|
"uptime": "Время работы бота",
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPPORTED_TAGS: frozenset[str] = frozenset(TAG_REGISTRY)
|
SUPPORTED_TAGS: frozenset[str] = frozenset(TAG_REGISTRY)
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.3.2"
|
__version__ = "0.3.3"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.3.2"
|
assert __version__ == "0.3.3"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue