Уведомления в чат логов и ежечасный пинг
- единая отправка уведомлений через 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`).
|
||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 считает контейнер живым,
|
||||
если файл свежий. Если 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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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": "Количество игроков в списке",
|
||||
"tops": "Топ игроков по сообщениям",
|
||||
"promo": "Промокод доната",
|
||||
# --- Служебные теги уведомлений в чат логов (ALERT_CHAT_ID) ---
|
||||
"chat_title": "Название чата",
|
||||
"version": "Версия бота",
|
||||
"uptime": "Время работы бота",
|
||||
}
|
||||
|
||||
SUPPORTED_TAGS: frozenset[str] = frozenset(TAG_REGISTRY)
|
||||
|
|
|
|||
|
|
@ -7,4 +7,4 @@
|
|||
|
||||
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():
|
||||
assert __version__
|
||||
assert __version__ == "0.3.2"
|
||||
assert __version__ == "0.3.3"
|
||||
|
|
|
|||
Loading…
Reference in New Issue