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
+8
View File
@@ -0,0 +1,8 @@
"""FSM storage factory. In-memory for now; Postgres-backed storage that survives restarts arrives with the DB (#5)."""
from aiogram.fsm.storage.base import BaseStorage
from aiogram.fsm.storage.memory import MemoryStorage
def make_storage() -> BaseStorage:
return MemoryStorage()
+58
View File
@@ -0,0 +1,58 @@
"""Main menu (reply keyboard) and in-section navigation (inline keyboard), SPEC §4.0."""
from enum import StrEnum
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
from app.texts import Texts
class Section(StrEnum):
COMMIT = "commit"
STATUS = "status"
LOGS = "logs"
ALERTS = "alerts"
MEMBERS = "members"
class NavAction(StrEnum):
BACK = "back"
CANCEL = "cancel"
class Nav(CallbackData, prefix="nav"):
# Telegram limits callback_data to 64 bytes: only short ids here, never paths (ARCHITECTURE §2.1).
action: NavAction
def section_by_button(texts: Texts) -> dict[str, Section]:
m = texts.menu
return {
m.commit: Section.COMMIT,
m.status: Section.STATUS,
m.logs: Section.LOGS,
m.alerts: Section.ALERTS,
m.members: Section.MEMBERS,
}
def main_menu(texts: Texts, *, is_admin: bool) -> ReplyKeyboardMarkup:
m = texts.menu
kb = ReplyKeyboardBuilder()
kb.button(text=m.commit)
kb.button(text=m.status)
kb.button(text=m.logs)
kb.button(text=m.alerts)
if is_admin:
kb.button(text=m.members)
kb.adjust(2, 2, 1)
return kb.as_markup(resize_keyboard=True, is_persistent=True, input_field_placeholder=m.placeholder)
def section_nav(texts: Texts) -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
kb.button(text=texts.menu.cancel, callback_data=Nav(action=NavAction.CANCEL))
return kb.as_markup()
View File
+51
View File
@@ -0,0 +1,51 @@
"""Access gate on every update — messages and button presses alike (SPEC A-5).
Until invites land (#25) the only members are the bootstrap admins from `ADMIN_IDS`.
"""
import time
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, TelegramObject
from app.texts import Texts
IsMember = Callable[[int], Awaitable[bool]]
NO_ACCESS_REPLY_EVERY = 24 * 3600.0
class AccessMiddleware(BaseMiddleware):
def __init__(self, is_member: IsMember, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
self._is_member = is_member
self._texts = texts
self._clock = clock
# tg_id -> when we last told this outsider "invite only"; answering every message would let them spam us.
self._last_refusal: dict[int, float] = {}
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
user = data.get("event_from_user")
if user is not None and await self._is_member(user.id):
return await handler(event, data)
if user is not None:
await self._refuse(event, user.id)
return None
async def _refuse(self, event: TelegramObject, tg_id: int) -> None:
if isinstance(event, CallbackQuery):
await event.answer() # stop the spinner on a stale button, say nothing
return
now = self._clock()
last = self._last_refusal.get(tg_id)
if last is not None and now - last < NO_ACCESS_REPLY_EVERY:
return
self._last_refusal[tg_id] = now
if isinstance(event, Message):
await event.answer(self._texts.start.no_access)
View File
+57
View File
@@ -0,0 +1,57 @@
"""/start, /help, /menu, main menu buttons and in-section navigation."""
from aiogram import F, Router
from aiogram.filters import Command, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from app.bot.menu import Nav, NavAction, Section, main_menu, section_by_button, section_nav
from app.texts import Texts
def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router:
router = Router(name="common")
sections = section_by_button(texts)
section_text = {
Section.COMMIT: texts.section.commit,
Section.STATUS: texts.section.status,
Section.LOGS: texts.section.logs,
Section.ALERTS: texts.section.alerts,
Section.MEMBERS: texts.section.members,
}
def is_admin(message: Message) -> bool:
return message.from_user is not None and message.from_user.id in admin_ids
@router.message(CommandStart())
async def start(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=is_admin(message)))
@router.message(Command("menu"))
async def menu(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=is_admin(message)))
@router.message(Command("help"))
async def help_(message: Message) -> None:
await message.answer(texts.help.text)
@router.message(F.text.in_(sections))
async def open_section(message: Message, state: FSMContext) -> None:
section = sections[message.text or ""]
if section is Section.MEMBERS and not is_admin(message):
return
await state.clear()
await message.answer(section_text[section], reply_markup=section_nav(texts))
@router.callback_query(Nav.filter())
async def navigate(query: CallbackQuery, callback_data: Nav, state: FSMContext) -> None:
# At a section's root "Back" and "Cancel" both close it; nested screens will override "Back" per section.
await state.clear()
text = texts.section.cancelled if callback_data.action is NavAction.CANCEL else texts.menu.placeholder
if isinstance(query.message, Message):
await query.message.edit_text(text)
await query.answer()
return router