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
+44 -16
View File
@@ -1,28 +1,56 @@
"""Entry point. For now a stub that idles until stopped; the bot itself lands in #4."""
"""Entry point: settings → texts → dispatcher → long polling (ADR-002)."""
import asyncio
import contextlib
import logging
import signal
from aiogram import Bot, Dispatcher
from app import __version__
from app.bot.fsm import make_storage
from app.bot.middlewares.access import AccessMiddleware
from app.bot.routers.common import build_router
from app.config import Settings
from app.health import heartbeat
from app.logs_setup import setup_logging
from app.texts import Texts, load_texts
log = logging.getLogger("tgit")
async def run(stop: asyncio.Event) -> None:
log.info("tgit %s started", __version__)
await stop.wait()
log.info("tgit stopped")
def build_dispatcher(settings: Settings, texts: Texts) -> Dispatcher:
admin_ids = settings.access.admin_ids
async def is_member(tg_id: int) -> bool:
return tg_id in admin_ids
dp = Dispatcher(storage=make_storage())
access = AccessMiddleware(is_member, texts)
# Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked.
dp.message.outer_middleware(access)
dp.callback_query.outer_middleware(access)
dp.include_router(build_router(texts, admin_ids))
return dp
async def run(settings: Settings) -> None:
texts = load_texts(settings.runtime.config_dir)
dp = build_dispatcher(settings, texts)
rt = settings.runtime
beat = asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval))
bot = Bot(settings.telegram.bot_token.get_secret_value())
log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids))
try:
await dp.start_polling(bot, handle_signals=True)
finally:
beat.cancel()
with contextlib.suppress(asyncio.CancelledError):
await beat
await bot.session.close()
log.info("tgit stopped")
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
async def _main() -> None:
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
await run(stop)
asyncio.run(_main())
settings = Settings()
setup_logging(settings.runtime.log_level)
asyncio.run(run(settings))