Files
ElebobaandClaude Opus 5.5 6c6f766abc
CI / ruff · mypy · pytest (pull_request) Successful in 4m49s
feat(alerts): алерты всей команде — детекторы, дедупликация, список (#15)
- детекторы по состоянию после каждого раунда снапшотов: краш, OOM,
  цикл рестартов, unhealthy, перегрузка ≥ 1 мин, мало места, Docker
  недоступен; пороги в config/alerts.yaml
- AlertService: ключ kind+service, повтор в 10 мин — счётчик в исходном
  сообщении, «✅ восстановлено» ответом; разовые события (🛡) со счётчиком
- рассылка всем активным (🛡 — только админам), темп ~25/с, RetryAfter,
  заблокировавший бота пропускается; alert_deliveries для правок и ответов
- «🚨 Алерты»: активные + 7 дней по 10, карточка; кнопки «📜 Логи»,
  «🩺 Статус» под алертом открывают новое сообщение, а не затирают алерт
- 🛡 атаки через агента идут через общий сервис алертов
- убраны заглушки «Раздел в разработке» — у всех разделов свои роутеры

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:36:09 +03:00

147 lines
6.2 KiB
Python

"""Offline Telegram: a fake aiogram session that records outgoing API calls and fabricates replies."""
from collections.abc import AsyncGenerator, Callable
from datetime import UTC, datetime
from itertools import count
from typing import Any
from aiogram import Bot, Dispatcher
from aiogram.client.session.base import BaseSession
from aiogram.exceptions import TelegramForbiddenError
from aiogram.methods import EditMessageText, GetFile, GetMe, SendMessage, SendPhoto, TelegramMethod
from aiogram.methods.base import TelegramType
from aiogram.types import Chat, File, Message, Update, User
BOT_TOKEN = "42:TEST-TOKEN"
class FakeSession(BaseSession):
def __init__(self) -> None:
super().__init__()
self.requests: list[TelegramMethod[Any]] = []
self._ids = count(1000)
self.files: dict[str, bytes] = {} # file_id -> content, for documents users "upload"
self.blocked: set[int] = set() # chats that blocked the bot: sends there raise 403
async def make_request(
self,
bot: Bot,
method: TelegramMethod[TelegramType],
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
) -> TelegramType:
self.requests.append(method)
if isinstance(method, SendMessage) and method.chat_id in self.blocked:
raise TelegramForbiddenError(method=method, message="Forbidden: bot was blocked by the user")
if isinstance(method, GetFile):
return File( # type: ignore[return-value]
file_id=method.file_id, file_unique_id=method.file_id, file_path=f"documents/{method.file_id}"
)
if isinstance(method, GetMe):
return User(id=42, is_bot=True, first_name="tgit", username="tgit_test_bot") # type: ignore[return-value]
if isinstance(method, SendPhoto):
chat = method.chat_id if isinstance(method.chat_id, int) else 0
return Message( # type: ignore[return-value]
message_id=next(self._ids),
date=datetime.now(UTC),
chat=Chat(id=chat, type="private"),
caption=method.caption,
)
if isinstance(method, SendMessage | EditMessageText):
chat_id = method.chat_id if isinstance(method.chat_id, int) else 0
return Message( # type: ignore[return-value]
message_id=next(self._ids),
date=datetime.now(UTC),
chat=Chat(id=chat_id, type="private"),
text=method.text,
)
return True # type: ignore[return-value]
async def stream_content(
self,
url: str,
headers: dict[str, Any] | None = None,
timeout: int = 30, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
chunk_size: int = 65536,
raise_for_status: bool = True,
) -> AsyncGenerator[bytes, None]:
file_id = url.rsplit("/", 1)[-1]
yield self.files[file_id]
async def close(self) -> None:
pass
def sent(self, method_type: type[TelegramMethod[Any]]) -> list[Any]:
return [r for r in self.requests if isinstance(r, method_type)]
class TgClient:
"""Feeds updates through a real Dispatcher (filters, middlewares, FSM) on top of FakeSession."""
def __init__(self, dp: Dispatcher | Callable[[Bot], Dispatcher]) -> None:
"""dp: a dispatcher, or a factory for one that needs the bot (alert broadcasts send through it)."""
self.session = FakeSession()
self.bot = Bot(BOT_TOKEN, session=self.session)
self.dp = dp if isinstance(dp, Dispatcher) else dp(self.bot)
self._update_ids = count(1)
self._message_ids = count(1)
@staticmethod
def user(tg_id: int) -> dict[str, Any]:
return {"id": tg_id, "is_bot": False, "first_name": f"u{tg_id}", "username": f"u{tg_id}"}
async def send(self, tg_id: int, text: str) -> None:
message = {
"message_id": next(self._message_ids),
"date": int(datetime.now(UTC).timestamp()),
"chat": {"id": tg_id, "type": "private"},
"from": self.user(tg_id),
"text": text,
}
if text.startswith("/"):
cmd = text.split()[0]
message["entities"] = [{"type": "bot_command", "offset": 0, "length": len(cmd)}]
await self._feed({"message": message})
async def send_document(self, tg_id: int, name: str, content: bytes) -> None:
file_id = f"doc{next(self._message_ids)}"
self.session.files[file_id] = content
message = {
"message_id": next(self._message_ids),
"date": int(datetime.now(UTC).timestamp()),
"chat": {"id": tg_id, "type": "private"},
"from": self.user(tg_id),
"document": {"file_id": file_id, "file_unique_id": file_id, "file_name": name, "file_size": len(content)},
}
await self._feed({"message": message})
async def press_markup(self, tg_id: int, data: str, markup: Any) -> None:
"""Press a button on a message that carries this inline keyboard (some handlers look at it)."""
await self.press(tg_id, data, reply_markup=markup.model_dump(exclude_none=True) if markup else None)
async def press(
self, tg_id: int, data: str, message_text: str = "section", reply_markup: dict[str, Any] | None = None
) -> None:
callback = {
"id": str(next(self._update_ids)),
"from": self.user(tg_id),
"chat_instance": "ci",
"data": data,
"message": {
"message_id": next(self._message_ids),
"date": int(datetime.now(UTC).timestamp()),
"chat": {"id": tg_id, "type": "private"},
"from": {"id": 42, "is_bot": True, "first_name": "tgit"},
"text": message_text,
**({"reply_markup": reply_markup} if reply_markup else {}),
},
}
await self._feed({"callback_query": callback})
async def _feed(self, payload: dict[str, Any]) -> None:
update = Update.model_validate({"update_id": next(self._update_ids), **payload}, context={"bot": self.bot})
await self.dp.feed_update(self.bot, update)
def as_user(tg_id: int) -> User:
return User(id=tg_id, is_bot=False, first_name=f"u{tg_id}")