"""Entry point: settings → texts → dispatcher → long polling (ADR-002).""" import asyncio import contextlib import logging from aiogram import Bot, Dispatcher from aiogram.client.session.aiohttp import AiohttpSession from app import __version__ from app.access.service import AccessService from app.bot.fsm import make_storage from app.bot.middlewares.access import AccessMiddleware from app.bot.routers import commit, common, members, status from app.config import Settings from app.db.maintenance import cleanup_loop from app.db.migrate import upgrade from app.db.session import SessionMaker, make_engine, make_sessionmaker from app.health import heartbeat from app.logs_setup import setup_logging from app.repo.drafts import DraftService from app.repo.git import UploadsRepo from app.repo.publisher import Publisher from app.runtime.checks import Checker, load_checks from app.runtime.docker import DockerClient from app.runtime.snapshots import SnapshotStore, snapshot_loop from app.texts import Texts, load_texts log = logging.getLogger("tgit") def build_dispatcher( texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo, commit_url_template: str = "", docker: DockerClient | None = None, checker: Checker | None = None, ) -> Dispatcher: dp = Dispatcher(storage=make_storage(sessions)) gate = AccessMiddleware(access, texts) # Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked. dp.message.outer_middleware(gate) dp.callback_query.outer_middleware(gate) dp.include_router(members.build_router(texts, access)) dp.include_router( commit.build_router(texts, repo, DraftService(sessions), Publisher(repo, sessions), commit_url_template) ) dp.include_router(status.build_router(texts, docker, checker)) dp.include_router(common.build_router(texts, access)) return dp async def run(settings: Settings) -> None: texts = load_texts(settings.runtime.config_dir) checks_cfg = load_checks(settings.runtime.config_dir) engine = make_engine(settings.database.url) await upgrade(engine) sessions = make_sessionmaker(engine) access = AccessService(sessions, settings.access) await access.bootstrap_admins() # No DOCKER_URL (e.g. `make run` without Docker): «Статус» answers «Docker недоступен», the rest works docker = DockerClient(settings.docker) if settings.docker.url else None store = SnapshotStore(sessions) checker = Checker(docker, store, checks_cfg) if docker else None dp = build_dispatcher( texts, sessions, access, UploadsRepo(settings.git), settings.git.commit_url_template, docker, checker ) rt = settings.runtime background = [ asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)), asyncio.create_task(cleanup_loop(sessions)), ] if docker: background.append(asyncio.create_task(snapshot_loop(docker, store, settings.docker.snapshot_interval))) proxy = settings.telegram.proxy_url session = AiohttpSession(proxy=proxy.get_secret_value()) if proxy else None bot = Bot(settings.telegram.bot_token.get_secret_value(), session=session) log.info( "tgit %s starting, %d bootstrap admin(s), telegram %s, docker %s", __version__, len(settings.access.admin_ids), "via proxy" if proxy else "direct", "on" if docker else "off", ) try: await dp.start_polling(bot, handle_signals=True) finally: for task in background: task.cancel() for task in background: with contextlib.suppress(asyncio.CancelledError): await task await bot.session.close() if docker: await docker.aclose() if checker: await checker.aclose() await engine.dispose() log.info("tgit stopped") def main() -> None: settings = Settings() setup_logging(settings.runtime.log_level) asyncio.run(run(settings))