"""Entry point: settings → texts → dispatcher → long polling (ADR-002).""" import asyncio import contextlib import logging from datetime import timedelta from aiogram import Bot, Dispatcher from aiogram.client.session.aiohttp import AiohttpSession from app import __version__ from app.access.service import AccessService from app.agent.service import Agent from app.alerts.broadcast import TelegramNotifier from app.alerts.detectors import load_alerts_config from app.alerts.monitor import AlertMonitor from app.alerts.service import AlertService from app.bot.fsm import make_storage from app.bot.middlewares.access import AccessMiddleware from app.bot.routers import alerts as alerts_router from app.bot.routers import commit, common, logs, 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.llm.factory import make_provider from app.logs.journal import LogJournal from app.logs_setup import setup_logging from app.pipeline.factory import make_ci from app.pipeline.watcher import PipelineWatcher from app.repo.drafts import DraftService from app.repo.git import UploadsRepo from app.repo.publisher import Publisher from app.runner.client import RunnerClient from app.runner.deployer import Deployer 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, agent: Agent | None = None, alerts: AlertService | None = None, runner: RunnerClient | None = None, pipeline: PipelineWatcher | 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, pipeline ) ) dp.include_router(status.build_router(texts, docker, checker, runner)) dp.include_router(logs.build_router(texts, docker, LogJournal(sessions), agent, alerts)) dp.include_router(alerts_router.build_router(texts, alerts, docker)) 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) alerts_cfg = load_alerts_config(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 # The mock provider answers `{}` to the guard — every question would be an «attack»: no real LLM, no agent llm = make_provider(settings.llm) if settings.llm.provider != "mock" else None agent = Agent.from_config(llm, sessions, settings.runtime.config_dir) if llm else None 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) alerts = AlertService( sessions, TelegramNotifier(bot, access, sessions, texts), alerts_cfg, log_source=(lambda cid, n: docker.logs(cid, tail=n)) if docker else None, ) repo = UploadsRepo(settings.git) runner = RunnerClient(settings.runner) if settings.runner.host else None ci = make_ci(settings.ci) deployer = Deployer(repo, runner, alerts) if runner else None # Without CI and runner there is nothing to watch: the push message just says what was pushed pipeline = ( PipelineWatcher( sessions, bot, texts, ci, deployer, commit_url_template=settings.git.commit_url_template, poll_interval=settings.ci.poll_interval, max_poll_interval=settings.ci.max_poll_interval, run_timeout=timedelta(minutes=settings.ci.run_timeout_minutes), ) if ci or deployer else None ) dp = build_dispatcher( texts, sessions, access, repo, settings.git.commit_url_template, docker, checker, agent, alerts, runner, pipeline, ) rt = settings.runtime background = [ asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)), asyncio.create_task(cleanup_loop(sessions)), ] if pipeline: background.append(asyncio.create_task(pipeline.run())) if docker: monitor = AlertMonitor(alerts, store, alerts_cfg) background.append( asyncio.create_task(snapshot_loop(docker, store, settings.docker.snapshot_interval, monitor.on_round)) ) log.info( "tgit %s starting, %d bootstrap admin(s), telegram %s, docker %s, agent %s, ci %s, runner %s", __version__, len(settings.access.admin_ids), "via proxy" if proxy else "direct", "on" if docker else "off", settings.llm.provider if agent else "off", settings.ci.provider if ci else "off", settings.runner.host or "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 if pipeline: await pipeline.aclose() if ci: await ci.aclose() await bot.session.close() if docker: await docker.aclose() if checker: await checker.aclose() if llm: await llm.aclose() await engine.dispose() log.info("tgit stopped") def main() -> None: settings = Settings() setup_logging(settings.runtime.log_level) asyncio.run(run(settings))