feat(bot): точка входа, конфиг, главное меню, доступ для ADMIN_IDS, healthcheck

- config: pydantic-settings по подсистемам, ADMIN_IDS через запятую
- texts.yaml + валидация при старте (лишний/пропущенный ключ — ошибка сразу)
- меню: reply-клавиатура разделов (Участники — только админам), инлайн «Назад»/«Отмена»
  с редактированием сообщения на месте, /start, /menu, /help
- AccessMiddleware (outer) на сообщениях и кнопках: до #25 пускает только ADMIN_IDS,
  постороннему — одна фраза в сутки, устаревшие кнопки молча гасятся
- heartbeat-файл + HEALTHCHECK в Dockerfile; JSON-логи
- FSM пока в памяти (фабрика), хранилище в БД — в #5
- тесты на фейковой сессии aiogram через настоящий Dispatcher

Refs #4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 23:53:52 +03:00
co-authored by Claude Opus 5.5
parent bbd4ac5bc1
commit 0ce5285da1
23 changed files with 698 additions and 29 deletions
View File
+75
View File
@@ -0,0 +1,75 @@
import pytest
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage
from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
from app.bot.menu import Nav, NavAction
from app.config import Settings
from app.main import build_dispatcher
from app.texts import Texts
from tests.conftest import ADMIN, OUTSIDER
from tests.fakes import TgClient
@pytest.fixture
def tg(settings: Settings, texts: Texts) -> TgClient:
return TgClient(build_dispatcher(settings, texts))
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
return [b.text for row in markup.keyboard for b in row]
async def test_admin_start_shows_welcome_and_full_menu(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, "/start")
[reply] = tg.session.sent(SendMessage)
assert reply.text == texts.start.welcome
assert isinstance(reply.reply_markup, ReplyKeyboardMarkup)
m = texts.menu
assert buttons(reply.reply_markup) == [m.commit, m.status, m.logs, m.alerts, m.members]
async def test_outsider_gets_one_refusal_then_silence(tg: TgClient, texts: Texts) -> None:
await tg.send(OUTSIDER, "/start")
await tg.send(OUTSIDER, "hello")
await tg.send(OUTSIDER, texts.menu.commit)
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.no_access]
async def test_outsider_stale_button_is_swallowed(tg: TgClient) -> None:
await tg.press(OUTSIDER, Nav(action=NavAction.BACK).pack())
assert tg.session.sent(EditMessageText) == []
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts", "members"])
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
await tg.send(ADMIN, getattr(texts.menu, section))
[reply] = tg.session.sent(SendMessage)
assert reply.text == getattr(texts.section, section)
assert isinstance(reply.reply_markup, InlineKeyboardMarkup)
labels = [b.text for row in reply.reply_markup.inline_keyboard for b in row]
assert labels == [texts.menu.back, texts.menu.cancel]
@pytest.mark.parametrize(
("action", "expected"),
[(NavAction.BACK, "placeholder"), (NavAction.CANCEL, "cancelled")],
)
async def test_nav_edits_section_message_in_place(tg: TgClient, texts: Texts, action: NavAction, expected: str) -> None:
await tg.press(ADMIN, Nav(action=action).pack())
[edit] = tg.session.sent(EditMessageText)
want = texts.menu.placeholder if expected == "placeholder" else texts.section.cancelled
assert edit.text == want
assert tg.session.sent(SendMessage) == []
async def test_menu_and_help_commands(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, "/menu")
await tg.send(ADMIN, "/help")
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.menu.placeholder, texts.help.text]
+24
View File
@@ -0,0 +1,24 @@
from pathlib import Path
import pytest
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings
from app.texts import Texts, load_texts
ROOT = Path(__file__).resolve().parent.parent
ADMIN = 1001
OUTSIDER = 2002
@pytest.fixture
def texts() -> Texts:
return load_texts(ROOT / "config")
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
telegram=TelegramSettings(bot_token="42:TEST-TOKEN"),
access=AccessSettings(admin_ids=frozenset({ADMIN})),
runtime=RuntimeSettings(config_dir=ROOT / "config", heartbeat_file=tmp_path / "hb"),
)
+107
View File
@@ -0,0 +1,107 @@
"""Offline Telegram: a fake aiogram session that records outgoing API calls and fabricates replies."""
from collections.abc import AsyncGenerator
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.methods import EditMessageText, SendMessage, TelegramMethod
from aiogram.methods.base import TelegramType
from aiogram.types import Chat, 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)
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 | 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]: # pragma: no cover - not used
raise NotImplementedError
yield b""
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) -> None:
self.dp = dp
self.session = FakeSession()
self.bot = Bot(BOT_TOKEN, session=self.session)
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 press(self, tg_id: int, data: str, message_text: str = "section") -> 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,
},
}
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}")
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.config import AccessSettings
from app.texts import load_texts
def test_admin_ids_parsed_from_comma_separated_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ADMIN_IDS", "1, 2,3,")
assert AccessSettings(_env_file=None).admin_ids == frozenset({1, 2, 3}) # type: ignore[call-arg]
def test_admin_ids_default_empty(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ADMIN_IDS", raising=False)
assert AccessSettings(_env_file=None).admin_ids == frozenset() # type: ignore[call-arg]
def test_texts_with_missing_key_fail_fast(tmp_path: Path) -> None:
(tmp_path / "texts.yaml").write_text("menu: {commit: x}\n", encoding="utf-8")
with pytest.raises(ValidationError):
load_texts(tmp_path)
+41
View File
@@ -0,0 +1,41 @@
import asyncio
import contextlib
import os
from pathlib import Path
import pytest
from app import health
def test_missing_file_is_dead(tmp_path: Path) -> None:
assert not health.is_alive(tmp_path / "hb", max_age=30)
def test_age_decides(tmp_path: Path) -> None:
hb = tmp_path / "hb"
hb.touch()
os.utime(hb, (1000, 1000))
assert health.is_alive(hb, max_age=30, now=1029)
assert not health.is_alive(hb, max_age=30, now=1031)
async def test_heartbeat_touches_file(tmp_path: Path) -> None:
hb = tmp_path / "hb"
task = asyncio.create_task(health.heartbeat(hb, interval=10))
await asyncio.sleep(0)
assert hb.exists()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
@pytest.mark.parametrize(("touch", "code"), [(True, 0), (False, 1)])
def test_cli_exit_code(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, touch: bool, code: int) -> None:
hb = tmp_path / "hb"
if touch:
hb.touch()
monkeypatch.setenv("HEARTBEAT_FILE", str(hb))
with pytest.raises(SystemExit) as exc:
health.main()
assert exc.value.code == code
+12
View File
@@ -0,0 +1,12 @@
import json
import logging
from app.logs_setup import JsonFormatter
def test_json_formatter_one_object_per_line() -> None:
record = logging.LogRecord("tgit", logging.INFO, __file__, 1, "hello %s", ("мир",), None)
entry = json.loads(JsonFormatter().format(record))
assert entry["msg"] == "hello мир"
assert entry["level"] == "INFO"
assert entry["logger"] == "tgit"
-13
View File
@@ -1,13 +0,0 @@
import asyncio
from app.main import run
async def test_run_returns_when_stop_is_set() -> None:
stop = asyncio.Event()
task = asyncio.create_task(run(stop))
await asyncio.sleep(0)
assert not task.done()
stop.set()
await asyncio.wait_for(task, timeout=1)