Files
Nikolay Tatarinov 13aba85e28
All checks were successful
CI / lint-and-test (push) Successful in 1m2s
Docker Build and Release / build-and-push (push) Successful in 49s
Docker Build and Release / release (push) Successful in 9s
chore(release): v2.0.4
Made-with: Cursor
2026-03-04 18:39:37 +03:00

139 lines
4.5 KiB
Python

"""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"])