Refactor configuration and enhance Telegram initData validation

- Improved formatting and readability in config.py and other files by adding line breaks.
- Introduced INIT_DATA_MAX_AGE_SECONDS to enforce replay protection for Telegram initData.
- Updated validate_init_data function to include max_age_seconds parameter for validation.
- Enhanced API to reject old initData based on the new max_age_seconds setting.
- Added tests for auth_date expiry and validation of initData in test_telegram_auth.py.
- Updated README with details on the new INIT_DATA_MAX_AGE_SECONDS configuration.
This commit is contained in:
2026-02-17 17:31:20 +03:00
parent d20a285f09
commit 1948618394
19 changed files with 181 additions and 25 deletions

View File

@@ -40,6 +40,7 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co
- `DATABASE_URL` DB connection (default: `sqlite:///data/duty_teller.db`). - `DATABASE_URL` DB connection (default: `sqlite:///data/duty_teller.db`).
- `MINI_APP_BASE_URL` Base URL of the miniapp (for documentation / CORS). - `MINI_APP_BASE_URL` Base URL of the miniapp (for documentation / CORS).
- `MINI_APP_SKIP_AUTH` Set to `1` to allow `/api/duties` without Telegram initData (dev only; insecure). - `MINI_APP_SKIP_AUTH` Set to `1` to allow `/api/duties` without Telegram initData (dev only; insecure).
- `INIT_DATA_MAX_AGE_SECONDS` Reject Telegram initData older than this (e.g. `86400` = 24h). `0` = disabled (default).
- `CORS_ORIGINS` Comma-separated allowed origins for CORS, or leave unset for `*`. - `CORS_ORIGINS` Comma-separated allowed origins for CORS, or leave unset for `*`.
## Run ## Run
@@ -66,6 +67,8 @@ Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
``` ```
For production deployments you may use Docker secrets or your orchestrators env instead of a `.env` file. For production deployments you may use Docker secrets or your orchestrators env instead of a `.env` file.
**Production behind a reverse proxy:** When the app is behind nginx/Caddy etc., `request.client.host` is usually the proxy (e.g. 127.0.0.1). The "private IP" bypass (allowing requests without initData from localhost) then applies to the proxy, not the real client. Either ensure the Mini App always sends initData, or forward the real client IP (e.g. `X-Forwarded-For`) and use it for that check. See `api/app.py` `_is_private_client` for details.
## Project layout ## Project layout
- `main.py` Builds the `Application`, registers handlers, runs polling and FastAPI in a thread. - `main.py` Builds the `Application`, registers handlers, runs polling and FastAPI in a thread.
@@ -88,4 +91,4 @@ pip install -r requirements-dev.txt
pytest pytest
``` ```
Tests cover `api/telegram_auth` (validate_init_data), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth). Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth, plus an E2E auth test without auth mocks).

View File

@@ -1,4 +1,5 @@
"""Alembic env: use config DATABASE_URL and db.models.Base.""" """Alembic env: use config DATABASE_URL and db.models.Base."""
import os import os
import sys import sys
from logging.config import fileConfig from logging.config import fileConfig

View File

@@ -5,6 +5,7 @@ Revises:
Create Date: 2025-02-17 Create Date: 2025-02-17
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -34,7 +35,10 @@ def upgrade() -> None:
sa.Column("user_id", sa.Integer(), nullable=False), sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("start_at", sa.Text(), nullable=False), sa.Column("start_at", sa.Text(), nullable=False),
sa.Column("end_at", sa.Text(), nullable=False), sa.Column("end_at", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ), sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )

View File

@@ -1,4 +1,5 @@
"""FastAPI app: /api/duties and static webapp.""" """FastAPI app: /api/duties and static webapp."""
import logging import logging
import re import re
from pathlib import Path from pathlib import Path
@@ -50,7 +51,15 @@ def _fetch_duties_response(from_date: str, to_date: str) -> list[DutyWithUser]:
def _is_private_client(client_host: str | None) -> bool: def _is_private_client(client_host: str | None) -> bool:
"""True if client is localhost or private LAN (dev / same-machine access).""" """True if client is localhost or private LAN (dev / same-machine access).
Note: Behind a reverse proxy (e.g. nginx, Caddy), request.client.host is often
the proxy address (e.g. 127.0.0.1). Then "private client" would be true for all
requests when initData is missing. For production, either rely on the Mini App
always sending initData, or configure the proxy to forward the real client IP
(e.g. X-Forwarded-For) and use that for this check. Do not rely on the private-IP
bypass when deployed behind a proxy without one of these measures.
"""
if not client_host: if not client_host:
return False return False
if client_host in ("127.0.0.1", "::1"): if client_host in ("127.0.0.1", "::1"):
@@ -84,19 +93,28 @@ def list_duties(
x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"), x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"),
) -> list[DutyWithUser]: ) -> list[DutyWithUser]:
_validate_duty_dates(from_date, to_date) _validate_duty_dates(from_date, to_date)
log.info("GET /api/duties from %s, has initData: %s", request.client.host if request.client else "?", bool((x_telegram_init_data or "").strip())) log.info(
"GET /api/duties from %s, has initData: %s",
request.client.host if request.client else "?",
bool((x_telegram_init_data or "").strip()),
)
init_data = (x_telegram_init_data or "").strip() init_data = (x_telegram_init_data or "").strip()
if not init_data: if not init_data:
client_host = request.client.host if request.client else None client_host = request.client.host if request.client else None
if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH: if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH:
if config.MINI_APP_SKIP_AUTH: if config.MINI_APP_SKIP_AUTH:
log.warning("duties: allowing without initData (MINI_APP_SKIP_AUTH is set)") log.warning(
"duties: allowing without initData (MINI_APP_SKIP_AUTH is set)"
)
return _fetch_duties_response(from_date, to_date) return _fetch_duties_response(from_date, to_date)
log.warning("duties: no X-Telegram-Init-Data header (client=%s)", client_host) log.warning("duties: no X-Telegram-Init-Data header (client=%s)", client_host)
raise HTTPException(status_code=403, detail="Откройте календарь из Telegram") raise HTTPException(status_code=403, detail="Откройте календарь из Telegram")
username = validate_init_data(init_data, config.BOT_TOKEN) max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
username = validate_init_data(init_data, config.BOT_TOKEN, max_age_seconds=max_age)
if username is None: if username is None:
log.warning("duties: initData validation failed (invalid signature or no username)") log.warning(
"duties: initData validation failed (invalid signature or no username)"
)
raise HTTPException(status_code=403, detail="Неверные данные авторизации") raise HTTPException(status_code=403, detail="Неверные данные авторизации")
if not config.can_access_miniapp(username): if not config.can_access_miniapp(username):
log.warning("duties: username not in allowlist") log.warning("duties: username not in allowlist")

View File

@@ -1,17 +1,26 @@
"""Validate Telegram Web App initData and extract user username.""" """Validate Telegram Web App initData and extract user username."""
import hashlib import hashlib
import hmac import hmac
import json import json
import time
from urllib.parse import unquote from urllib.parse import unquote
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app # Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
# Data-check string must use the same key=value pairs as received (sorted by key); we preserve raw values. # Data-check string must use the same key=value pairs as received (sorted by key); we preserve raw values.
def validate_init_data(init_data: str, bot_token: str) -> str | None: def validate_init_data(
init_data: str,
bot_token: str,
max_age_seconds: int | None = None,
) -> str | None:
""" """
Validate initData signature and return the Telegram username (lowercase, no @). Validate initData signature and return the Telegram username (lowercase, no @).
Returns None if data is invalid, forged, or user has no username. Returns None if data is invalid, forged, or user has no username.
If max_age_seconds is set, initData must include auth_date and it must be no older
than max_age_seconds (replay protection). Example: 86400 = 24 hours.
""" """
if not init_data or not bot_token: if not init_data or not bot_token:
return None return None
@@ -45,6 +54,17 @@ def validate_init_data(init_data: str, bot_token: str) -> str | None:
).hexdigest() ).hexdigest()
if not hmac.compare_digest(computed.lower(), hash_val.lower()): if not hmac.compare_digest(computed.lower(), hash_val.lower()):
return None return None
# Optional replay protection: reject initData older than max_age_seconds
if max_age_seconds is not None and max_age_seconds > 0:
auth_date_raw = params.get("auth_date")
if not auth_date_raw:
return None
try:
auth_date = int(float(auth_date_raw))
except (ValueError, TypeError):
return None
if time.time() - auth_date > max_age_seconds:
return None
# Parse user JSON (value may be URL-encoded in the raw string) # Parse user JSON (value may be URL-encoded in the raw string)
user_raw = params.get("user") user_raw = params.get("user")
if not user_raw: if not user_raw:

View File

@@ -1,10 +1,14 @@
"""Tests for FastAPI app /api/duties.""" """Tests for FastAPI app /api/duties."""
import time
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import config
from api.app import app from api.app import app
from api.test_telegram_auth import _make_init_data
@pytest.fixture @pytest.fixture
@@ -101,3 +105,28 @@ def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
assert len(r.json()) == 1 assert len(r.json()) == 1
assert r.json()[0]["full_name"] == "Иван Иванов" assert r.json()[0]["full_name"] == "Иван Иванов"
mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31") mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31")
def test_duties_e2e_auth_real_validation(client, monkeypatch):
"""E2E: valid initData + allowlist, no mocks on validate_init_data; full auth path."""
test_token = "123:ABC"
test_username = "e2euser"
monkeypatch.setattr(config, "BOT_TOKEN", test_token)
monkeypatch.setattr(config, "ALLOWED_USERNAMES", {test_username})
monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
monkeypatch.setattr(config, "INIT_DATA_MAX_AGE_SECONDS", 0)
init_data = _make_init_data(
{"id": 1, "username": test_username},
test_token,
auth_date=int(time.time()),
)
with patch("api.app._fetch_duties_response") as mock_fetch:
mock_fetch.return_value = []
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
headers={"X-Telegram-Init-Data": init_data},
)
assert r.status_code == 200
assert r.json() == []
mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31")

View File

@@ -1,19 +1,25 @@
"""Tests for api.telegram_auth.validate_init_data.""" """Tests for api.telegram_auth.validate_init_data."""
import hashlib import hashlib
import hmac import hmac
import json import json
from urllib.parse import quote, urlencode from urllib.parse import quote
import pytest
from api.telegram_auth import validate_init_data from api.telegram_auth import validate_init_data
def _make_init_data(user: dict | None, bot_token: str) -> str: def _make_init_data(
user: dict | None,
bot_token: str,
auth_date: int | None = None,
) -> str:
"""Build initData string with valid HMAC for testing.""" """Build initData string with valid HMAC for testing."""
params = {} params = {}
if user is not None: if user is not None:
params["user"] = quote(json.dumps(user)) params["user"] = quote(json.dumps(user))
if auth_date is not None:
params["auth_date"] = str(auth_date)
pairs = sorted(params.items()) pairs = sorted(params.items())
data_string = "\n".join(f"{k}={v}" for k, v in pairs) data_string = "\n".join(f"{k}={v}" for k, v in pairs)
secret_key = hmac.new( secret_key = hmac.new(
@@ -82,3 +88,36 @@ def test_empty_bot_token_returns_none():
user = {"id": 1, "username": "u"} user = {"id": 1, "username": "u"}
init_data = _make_init_data(user, "token") init_data = _make_init_data(user, "token")
assert validate_init_data(init_data, "") is None assert validate_init_data(init_data, "") is None
def test_auth_date_expiry_rejects_old_init_data():
"""When max_age_seconds is set, initData older than that is rejected."""
import time as t
bot_token = "123:ABC"
user = {"id": 1, "username": "testuser"}
# auth_date 100 seconds ago
old_ts = int(t.time()) - 100
init_data = _make_init_data(user, bot_token, auth_date=old_ts)
assert validate_init_data(init_data, bot_token, max_age_seconds=60) is None
assert validate_init_data(init_data, bot_token, max_age_seconds=200) == "testuser"
def test_auth_date_expiry_accepts_fresh_init_data():
"""Fresh auth_date within max_age_seconds is accepted."""
import time as t
bot_token = "123:ABC"
user = {"id": 1, "username": "testuser"}
fresh_ts = int(t.time()) - 10
init_data = _make_init_data(user, bot_token, auth_date=fresh_ts)
assert validate_init_data(init_data, bot_token, max_age_seconds=60) == "testuser"
def test_auth_date_expiry_requires_auth_date_when_max_age_set():
"""When max_age_seconds is set but auth_date is missing, return None."""
bot_token = "123:ABC"
user = {"id": 1, "username": "testuser"}
init_data = _make_init_data(user, bot_token) # no auth_date
assert validate_init_data(init_data, bot_token, max_age_seconds=86400) is None
assert validate_init_data(init_data, bot_token) == "testuser"

View File

@@ -1,4 +1,5 @@
"""Load configuration from environment. Fail fast if BOT_TOKEN is missing.""" """Load configuration from environment. Fail fast if BOT_TOKEN is missing."""
import os import os
from pathlib import Path from pathlib import Path
@@ -8,7 +9,9 @@ load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN") BOT_TOKEN = os.getenv("BOT_TOKEN")
if not BOT_TOKEN: if not BOT_TOKEN:
raise SystemExit("BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather.") raise SystemExit(
"BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather."
)
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/") MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
@@ -17,17 +20,28 @@ DATA_DIR = Path(__file__).resolve().parent / "data"
# Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed. # Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed.
_raw_allowed = os.getenv("ALLOWED_USERNAMES", "").strip() _raw_allowed = os.getenv("ALLOWED_USERNAMES", "").strip()
ALLOWED_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_allowed.split(",") if s.strip()} ALLOWED_USERNAMES = {
s.strip().lstrip("@").lower() for s in _raw_allowed.split(",") if s.strip()
}
_raw_admin = os.getenv("ADMIN_USERNAMES", "").strip() _raw_admin = os.getenv("ADMIN_USERNAMES", "").strip()
ADMIN_USERNAMES = {s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip()} ADMIN_USERNAMES = {
s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip()
}
# Dev only: set to 1 to allow /api/duties without Telegram initData (insecure, no user check). # Dev only: set to 1 to allow /api/duties without Telegram initData (insecure, no user check).
MINI_APP_SKIP_AUTH = os.getenv("MINI_APP_SKIP_AUTH", "").strip() in ("1", "true", "yes") MINI_APP_SKIP_AUTH = os.getenv("MINI_APP_SKIP_AUTH", "").strip() in ("1", "true", "yes")
# Optional replay protection: reject initData older than this many seconds. 0 = disabled (default).
INIT_DATA_MAX_AGE_SECONDS = int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0"))
# CORS: comma-separated origins, or empty/"*" for allow all. For production, set to MINI_APP_BASE_URL or specific origins. # CORS: comma-separated origins, or empty/"*" for allow all. For production, set to MINI_APP_BASE_URL or specific origins.
_raw_cors = os.getenv("CORS_ORIGINS", "").strip() _raw_cors = os.getenv("CORS_ORIGINS", "").strip()
CORS_ORIGINS = [_o.strip() for _o in _raw_cors.split(",") if _o.strip()] if _raw_cors and _raw_cors != "*" else ["*"] CORS_ORIGINS = (
[_o.strip() for _o in _raw_cors.split(",") if _o.strip()]
if _raw_cors and _raw_cors != "*"
else ["*"]
)
def is_admin(username: str) -> bool: def is_admin(username: str) -> bool:

View File

@@ -1,4 +1,5 @@
"""Pytest configuration. Set BOT_TOKEN so config module can be imported.""" """Pytest configuration. Set BOT_TOKEN so config module can be imported."""
import os import os
# Set before any project code imports config (which requires BOT_TOKEN). # Set before any project code imports config (which requires BOT_TOKEN).

View File

@@ -1,4 +1,5 @@
"""Database layer: SQLAlchemy models, Pydantic schemas, repository, init.""" """Database layer: SQLAlchemy models, Pydantic schemas, repository, init."""
from db.models import Base, User, Duty from db.models import Base, User, Duty
from db.schemas import UserCreate, UserInDb, DutyCreate, DutyInDb, DutyWithUser from db.schemas import UserCreate, UserInDb, DutyCreate, DutyInDb, DutyWithUser
from db.session import get_engine, get_session_factory, get_session from db.session import get_engine, get_session_factory, get_session

View File

@@ -1,10 +1,12 @@
"""SQLAlchemy ORM models for users and duties.""" """SQLAlchemy ORM models for users and duties."""
from sqlalchemy import ForeignKey, Integer, BigInteger, Text from sqlalchemy import ForeignKey, Integer, BigInteger, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase): class Base(DeclarativeBase):
"""Declarative base for all models.""" """Declarative base for all models."""
pass pass
@@ -12,7 +14,9 @@ class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
telegram_user_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False) telegram_user_id: Mapped[int] = mapped_column(
BigInteger, unique=True, nullable=False
)
full_name: Mapped[str] = mapped_column(Text, nullable=False) full_name: Mapped[str] = mapped_column(Text, nullable=False)
username: Mapped[str | None] = mapped_column(Text, nullable=True) username: Mapped[str | None] = mapped_column(Text, nullable=True)
first_name: Mapped[str | None] = mapped_column(Text, nullable=True) first_name: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -25,7 +29,9 @@ class Duty(Base):
__tablename__ = "duties" __tablename__ = "duties"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
start_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601 start_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601
end_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601 end_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601

View File

@@ -1,4 +1,5 @@
"""Repository: get_or_create_user, get_duties, insert_duty.""" """Repository: get_or_create_user, get_duties, insert_duty."""
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from db.models import User, Duty from db.models import User, Duty

View File

@@ -1,4 +1,5 @@
"""Pydantic schemas for API and validation.""" """Pydantic schemas for API and validation."""
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -23,7 +24,7 @@ class UserInDb(UserBase):
class DutyBase(BaseModel): class DutyBase(BaseModel):
user_id: int user_id: int
start_at: str # ISO 8601 start_at: str # ISO 8601
end_at: str # ISO 8601 end_at: str # ISO 8601
class DutyCreate(DutyBase): class DutyCreate(DutyBase):
@@ -38,6 +39,7 @@ class DutyInDb(DutyBase):
class DutyWithUser(DutyInDb): class DutyWithUser(DutyInDb):
"""Duty with full_name for calendar display.""" """Duty with full_name for calendar display."""
full_name: str full_name: str
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -1,11 +1,11 @@
"""SQLAlchemy engine and session factory.""" """SQLAlchemy engine and session factory."""
from contextlib import contextmanager from contextlib import contextmanager
from typing import Generator from typing import Generator
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from db.models import Base
_engine = None _engine = None
_SessionLocal = None _SessionLocal = None
@@ -26,7 +26,9 @@ def get_engine(database_url: str):
if _engine is None: if _engine is None:
_engine = create_engine( _engine = create_engine(
database_url, database_url,
connect_args={"check_same_thread": False} if "sqlite" in database_url else {}, connect_args={"check_same_thread": False}
if "sqlite" in database_url
else {},
echo=False, echo=False,
) )
return _engine return _engine

View File

@@ -1,4 +1,5 @@
"""Expose a single register_handlers(app) that registers all handlers.""" """Expose a single register_handlers(app) that registers all handlers."""
from telegram.ext import Application from telegram.ext import Application
from . import commands, errors from . import commands, errors

View File

@@ -1,4 +1,5 @@
"""Command handlers: /start, /help; /start registers user and shows Calendar button.""" """Command handlers: /start, /help; /start registers user and shows Calendar button."""
import asyncio import asyncio
import config import config
@@ -15,7 +16,10 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
user = update.effective_user user = update.effective_user
if not user: if not user:
return return
full_name = " ".join(filter(None, [user.first_name or "", user.last_name or ""])).strip() or "User" full_name = (
" ".join(filter(None, [user.first_name or "", user.last_name or ""])).strip()
or "User"
)
telegram_user_id = user.id telegram_user_id = user.id
username = user.username username = user.username
first_name = user.first_name first_name = user.first_name
@@ -39,9 +43,16 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = "Привет! Я бот календаря дежурств. Используй /help для списка команд." text = "Привет! Я бот календаря дежурств. Используй /help для списка команд."
if config.MINI_APP_BASE_URL: if config.MINI_APP_BASE_URL:
keyboard = InlineKeyboardMarkup([ keyboard = InlineKeyboardMarkup(
[InlineKeyboardButton("📅 Календарь", web_app=WebAppInfo(url=config.MINI_APP_BASE_URL + "/app/"))], [
]) [
InlineKeyboardButton(
"📅 Календарь",
web_app=WebAppInfo(url=config.MINI_APP_BASE_URL + "/app/"),
)
],
]
)
await update.message.reply_text(text, reply_markup=keyboard) await update.message.reply_text(text, reply_markup=keyboard)
else: else:
await update.message.reply_text(text) await update.message.reply_text(text)

View File

@@ -1,4 +1,5 @@
"""Global error handler: log exception and notify user.""" """Global error handler: log exception and notify user."""
import logging import logging
from telegram import Update from telegram import Update

View File

@@ -1,4 +1,5 @@
"""Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint.""" """Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint."""
import asyncio import asyncio
import logging import logging
import threading import threading
@@ -18,6 +19,7 @@ logger = logging.getLogger(__name__)
def _run_uvicorn(web_app, port: int) -> None: def _run_uvicorn(web_app, port: int) -> None:
"""Run uvicorn in a dedicated thread with its own event loop.""" """Run uvicorn in a dedicated thread with its own event loop."""
import uvicorn import uvicorn
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
server = uvicorn.Server( server = uvicorn.Server(
@@ -31,6 +33,7 @@ def main() -> None:
register_handlers(app) register_handlers(app)
from api.app import app as web_app from api.app import app as web_app
t = threading.Thread( t = threading.Thread(
target=_run_uvicorn, target=_run_uvicorn,
args=(web_app, config.HTTP_PORT), args=(web_app, config.HTTP_PORT),

View File

@@ -1,5 +1,4 @@
"""Tests for config.is_admin and config.can_access_miniapp.""" """Tests for config.is_admin and config.can_access_miniapp."""
import pytest
import config import config