"""Application entry point: build bot Application, run HTTP server + polling.""" import asyncio import json import logging import sys import threading import time import urllib.request from telegram.ext import ApplicationBuilder from duty_teller import config from duty_teller.config import require_bot_token from duty_teller.handlers import group_duty_pin, register_handlers from duty_teller.utils.http_client import safe_urlopen # Seconds to wait for HTTP server to bind before health check. _HTTP_STARTUP_WAIT_SEC = 3 async def _post_init(application) -> None: """Run startup tasks: restore group pin jobs, then resolve bot username.""" await group_duty_pin.restore_group_pin_jobs(application) await _resolve_bot_username(application) async def _resolve_bot_username(application) -> None: """If BOT_USERNAME is not set from env, resolve it via get_me().""" if not config.BOT_USERNAME: me = await application.bot.get_me() config.BOT_USERNAME = (me.username or "").lower() logger.info("Resolved BOT_USERNAME from API: %s", config.BOT_USERNAME) logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=config.LOG_LEVEL, ) logger = logging.getLogger(__name__) def _set_default_menu_button_webapp() -> None: if not (config.MINI_APP_BASE_URL and config.BOT_TOKEN): return menu_url = (config.MINI_APP_BASE_URL.rstrip("/") + "/app/").strip() if not menu_url.startswith("https://"): return payload = { "menu_button": { "type": "web_app", "text": "Calendar", "web_app": {"url": menu_url}, } } req = urllib.request.Request( f"https://api.telegram.org/bot{config.BOT_TOKEN}/setChatMenuButton", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}, method="POST", ) try: with safe_urlopen(req, timeout=10) as resp: if resp.status == 200: logger.info("Default menu button set to Web App: %s", menu_url) else: logger.warning("setChatMenuButton returned %s", resp.status) except Exception as e: logger.warning("Could not set menu button: %s", e) def _run_uvicorn(web_app, port: int) -> None: import uvicorn loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) server = uvicorn.Server( uvicorn.Config(web_app, host=config.HTTP_HOST, port=port, log_level="info"), ) loop.run_until_complete(server.serve()) def _wait_for_http_ready(port: int) -> bool: """Return True if /health responds successfully within _HTTP_STARTUP_WAIT_SEC.""" host = config.HTTP_HOST if host not in config.LOOPBACK_HTTP_HOSTS: host = "127.0.0.1" url = f"http://{host}:{port}/health" deadline = time.monotonic() + _HTTP_STARTUP_WAIT_SEC while time.monotonic() < deadline: try: req = urllib.request.Request(url) with safe_urlopen(req, timeout=2) as resp: if resp.status == 200: return True except Exception as e: logger.debug("Health check not ready yet: %s", e) time.sleep(0.5) return False def main() -> None: """Build the bot and FastAPI, start uvicorn in a thread, run polling.""" require_bot_token() # Optional: set bot menu button to open the Miniapp. Uncomment to enable: # _set_default_menu_button_webapp() app = ApplicationBuilder().token(config.BOT_TOKEN).post_init(_post_init).build() register_handlers(app) from duty_teller.api.app import app as web_app if config.MINI_APP_SKIP_AUTH: logger.warning( "MINI_APP_SKIP_AUTH is set — API auth disabled (insecure); use only for dev" ) if config.HTTP_HOST not in config.LOOPBACK_HTTP_HOSTS: print( "ERROR: MINI_APP_SKIP_AUTH must not be used in production (non-localhost).", file=sys.stderr, ) sys.exit(1) t = threading.Thread( target=_run_uvicorn, args=(web_app, config.HTTP_PORT), daemon=False, ) t.start() if not _wait_for_http_ready(config.HTTP_PORT): logger.error( "HTTP server did not become ready on port %s within %s s; check port and permissions.", config.HTTP_PORT, _HTTP_STARTUP_WAIT_SEC, ) sys.exit(1) logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT) app.run_polling(allowed_updates=["message", "my_chat_member"])