Enhance Telegram bot functionality and improve error handling

- Introduced a new function to set the default menu button for the Telegram bot's Web App.
- Updated the initData validation process to provide detailed error messages for authorization failures.
- Refactored the validate_init_data function to return both username and reason for validation failure.
- Enhanced the web application to handle access denial more gracefully, providing users with hints on how to access the calendar.
- Improved the README with additional instructions for configuring the bot's menu button and Web App URL.
- Updated tests to reflect changes in the validation process and error handling.
This commit is contained in:
2026-02-17 19:08:14 +03:00
parent 1948618394
commit dd960dc5cc
11 changed files with 171 additions and 59 deletions

View File

@@ -34,7 +34,10 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co
Edit `.env` and set `BOT_TOKEN` to the token from BotFather.
5. **Miniapp access (calendar)**
To allow access to the calendar miniapp, set `ALLOWED_USERNAMES` to a comma-separated list of Telegram usernames (without `@`). Users in `ADMIN_USERNAMES` also have access; the admin role is reserved for future bot commands and API features. If both are empty, no one can open the calendar.
To allow access to the calendar miniapp, set `ALLOWED_USERNAMES` to a comma-separated list of Telegram usernames (without `@`). Users in `ADMIN_USERNAMES` also have access; the admin role is reserved for future bot commands and API features. If both are empty, no one can open the calendar.
**Mini App URL:** When configuring the bot's menu button or Web App URL (e.g. in @BotFather or via `setChatMenuButton`), use the URL **with a trailing slash**, e.g. `https://your-domain.com/app/`. A redirect from `/app` to `/app/` can cause the browser to drop the fragment that Telegram sends, which breaks authorization.
**How to open:** Users must open the calendar **via the bot's menu button** (⋮ → «Календарь» or the configured label) or a **Web App inline button**. If they use «Open in browser» or a direct link, Telegram may not send user data (`tgWebAppData`), and access will be denied.
**BOT_TOKEN:** The server that serves `/api/duties` (e.g. your production host) must have in `.env` the **same** bot token as the bot from which users open the Mini App. If the token differs (e.g. test vs production bot), validation returns "hash_mismatch" and access is denied.
6. **Optional env**
- `DATABASE_URL` DB connection (default: `sqlite:///data/duty_teller.db`).

View File

@@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
from db.session import session_scope
from db.repository import get_duties
from db.schemas import DutyWithUser
from api.telegram_auth import validate_init_data
from api.telegram_auth import validate_init_data_with_reason
log = logging.getLogger(__name__)
@@ -50,6 +50,16 @@ def _fetch_duties_response(from_date: str, to_date: str) -> list[DutyWithUser]:
]
def _auth_error_detail(auth_reason: str) -> str:
"""Return user-facing detail message for 403 when initData validation fails."""
if auth_reason == "hash_mismatch":
return (
"Неверная подпись. Убедитесь, что BOT_TOKEN на сервере совпадает с токеном бота, "
"из которого открыт календарь (тот же бот, что в меню)."
)
return "Неверные данные авторизации"
def _is_private_client(client_host: str | None) -> bool:
"""True if client is localhost or private LAN (dev / same-machine access).
@@ -110,12 +120,12 @@ def list_duties(
log.warning("duties: no X-Telegram-Init-Data header (client=%s)", client_host)
raise HTTPException(status_code=403, detail="Откройте календарь из Telegram")
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
username = validate_init_data(init_data, config.BOT_TOKEN, max_age_seconds=max_age)
username, auth_reason = validate_init_data_with_reason(
init_data, config.BOT_TOKEN, max_age_seconds=max_age
)
if username is None:
log.warning(
"duties: initData validation failed (invalid signature or no username)"
)
raise HTTPException(status_code=403, detail="Неверные данные авторизации")
log.warning("duties: initData validation failed: %s", auth_reason)
raise HTTPException(status_code=403, detail=_auth_error_detail(auth_reason))
if not config.can_access_miniapp(username):
log.warning("duties: username not in allowlist")
raise HTTPException(status_code=403, detail="Доступ запрещён")

View File

@@ -15,17 +15,23 @@ def validate_init_data(
bot_token: str,
max_age_seconds: int | None = None,
) -> str | None:
"""
Validate initData signature and return the Telegram username (lowercase, no @).
Returns None if data is invalid, forged, or user has no username.
"""Validate initData and return username; see validate_init_data_with_reason for failure reason."""
username, _ = validate_init_data_with_reason(init_data, bot_token, max_age_seconds)
return 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.
def validate_init_data_with_reason(
init_data: str,
bot_token: str,
max_age_seconds: int | None = None,
) -> tuple[str | None, str]:
"""
Validate initData signature and return (username, None) or (None, reason).
reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user", "user_invalid", "no_username".
"""
if not init_data or not bot_token:
return None
return (None, "empty")
init_data = init_data.strip()
# Parse preserving raw values for HMAC (key -> raw value)
params = {}
for part in init_data.split("&"):
if "=" not in part:
@@ -36,46 +42,41 @@ def validate_init_data(
params[key] = value
hash_val = params.pop("hash", None)
if not hash_val:
return None
# Build data-check string: keys sorted, format key=value per line (raw values)
return (None, "no_hash")
data_pairs = sorted(params.items())
data_string = "\n".join(f"{k}={v}" for k, v in data_pairs)
# secret_key = HMAC-SHA256(key=b"WebAppData", msg=bot_token.encode()).digest()
secret_key = hmac.new(
b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
# computed = HMAC-SHA256(key=secret_key, msg=data_string.encode()).hexdigest()
computed = hmac.new(
secret_key,
msg=data_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
return None
# Optional replay protection: reject initData older than max_age_seconds
return (None, "hash_mismatch")
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
return (None, "auth_date_expired")
try:
auth_date = int(float(auth_date_raw))
except (ValueError, TypeError):
return None
return (None, "auth_date_expired")
if time.time() - auth_date > max_age_seconds:
return None
# Parse user JSON (value may be URL-encoded in the raw string)
return (None, "auth_date_expired")
user_raw = params.get("user")
if not user_raw:
return None
return (None, "no_user")
try:
user = json.loads(unquote(user_raw))
except (json.JSONDecodeError, TypeError):
return None
return (None, "user_invalid")
if not isinstance(user, dict):
return None
return (None, "user_invalid")
username = user.get("username")
if not username or not isinstance(username, str):
return None
return username.strip().lstrip("@").lower()
return (None, "no_username")
return (username.strip().lstrip("@").lower(), "ok")

View File

@@ -53,22 +53,23 @@ def test_duties_200_when_skip_auth(mock_fetch, client):
mock_fetch.assert_called_once_with("2025-01-01", "2025-01-31")
@patch("api.app.validate_init_data")
@patch("api.app.validate_init_data_with_reason")
def test_duties_403_when_init_data_invalid(mock_validate, client):
mock_validate.return_value = None
mock_validate.return_value = (None, "hash_mismatch")
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
headers={"X-Telegram-Init-Data": "some=data&hash=abc"},
)
assert r.status_code == 403
assert "авторизации" in r.json()["detail"] or "Неверные" in r.json()["detail"]
detail = r.json()["detail"]
assert "авторизации" in detail or "Неверные" in detail or "Неверная" in detail
@patch("api.app.validate_init_data")
@patch("api.app.validate_init_data_with_reason")
@patch("api.app.config.can_access_miniapp")
def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client):
mock_validate.return_value = "someuser"
mock_validate.return_value = ("someuser", "ok")
mock_can_access.return_value = False
with patch("api.app._fetch_duties_response") as mock_fetch:
r = client.get(
@@ -81,10 +82,10 @@ def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, cl
mock_fetch.assert_not_called()
@patch("api.app.validate_init_data")
@patch("api.app.validate_init_data_with_reason")
@patch("api.app.config.can_access_miniapp")
def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
mock_validate.return_value = "alloweduser"
mock_validate.return_value = ("alloweduser", "ok")
mock_can_access.return_value = True
with patch("api.app._fetch_duties_response") as mock_fetch:
mock_fetch.return_value = [
@@ -108,7 +109,7 @@ def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
def test_duties_e2e_auth_real_validation(client, monkeypatch):
"""E2E: valid initData + allowlist, no mocks on validate_init_data; full auth path."""
"""E2E: valid initData + allowlist, no mocks on validate_init_data_with_reason; full auth path."""
test_token = "123:ABC"
test_username = "e2euser"
monkeypatch.setattr(config, "BOT_TOKEN", test_token)

View File

@@ -1,7 +1,6 @@
"""Load configuration from environment. Fail fast if BOT_TOKEN is missing."""
import os
from pathlib import Path
from dotenv import load_dotenv
@@ -16,7 +15,6 @@ if not BOT_TOKEN:
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080"))
DATA_DIR = Path(__file__).resolve().parent / "data"
# Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed.
_raw_allowed = os.getenv("ALLOWED_USERNAMES", "").strip()

View File

@@ -1,4 +1,10 @@
"""SQLAlchemy engine and session factory."""
"""SQLAlchemy engine and session factory.
Note: Engine and session factory are cached globally per process. Only one
DATABASE_URL is effectively used for the process lifetime. Using a different
URL later (e.g. in tests with in-memory SQLite) would still use the first
engine. To support multiple URLs, cache by database_url (e.g. a dict keyed by URL).
"""
from contextlib import contextmanager
from typing import Generator

View File

@@ -39,7 +39,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
finally:
session.close()
await asyncio.get_event_loop().run_in_executor(None, do_get_or_create)
await asyncio.get_running_loop().run_in_executor(None, do_get_or_create)
text = "Привет! Я бот календаря дежурств. Используй /help для списка команд."
if config.MINI_APP_BASE_URL:

View File

@@ -8,7 +8,7 @@ from telegram.ext import ContextTypes
logger = logging.getLogger(__name__)
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
async def error_handler(update: Update | None, context: ContextTypes.DEFAULT_TYPE) -> None:
logger.exception("Exception while handling an update")
if isinstance(update, Update) and update.effective_message:
await update.effective_message.reply_text("Произошла ошибка. Попробуйте позже.")

33
main.py
View File

@@ -1,8 +1,10 @@
"""Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint."""
import asyncio
import json
import logging
import threading
import urllib.request
import config
from telegram.ext import ApplicationBuilder
@@ -16,6 +18,36 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
def _set_default_menu_button_webapp() -> None:
"""Set the bot's default menu button to Web App so Telegram sends tgWebAppData when users open the app from the menu."""
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": "Календарь",
"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 urllib.request.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:
"""Run uvicorn in a dedicated thread with its own event loop."""
import uvicorn
@@ -29,6 +61,7 @@ def _run_uvicorn(web_app, port: int) -> None:
def main() -> None:
_set_default_menu_button_webapp()
app = ApplicationBuilder().token(config.BOT_TOKEN).build()
register_handlers(app)

View File

@@ -1,4 +1,8 @@
(function () {
const FETCH_TIMEOUT_MS = 15000;
const RETRY_DELAY_MS = 800;
const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
const MONTHS = [
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"
@@ -34,16 +38,33 @@
return new Date(d.getFullYear(), d.getMonth(), diff);
}
/** Get tgWebAppData value from hash when it contains unencoded & and = (URLSearchParams would split it). Value runs from tgWebAppData= until next &tgWebApp or end. */
function getTgWebAppDataFromHash(hash) {
var idx = hash.indexOf("tgWebAppData=");
if (idx === -1) return "";
var start = idx + "tgWebAppData=".length;
var end = hash.indexOf("&tgWebApp", start);
if (end === -1) end = hash.length;
var raw = hash.substring(start, end);
try {
return decodeURIComponent(raw);
} catch (e) {
return raw;
}
}
function getInitData() {
var fromSdk = (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || "";
if (fromSdk) return fromSdk;
var hash = window.location.hash ? window.location.hash.slice(1) : "";
if (hash.indexOf("tgWebAppData=") === 0) {
if (hash) {
var fromHash = getTgWebAppDataFromHash(hash);
if (fromHash) return fromHash;
try {
return decodeURIComponent(hash.substring("tgWebAppData=".length));
} catch (e) {
return hash.substring("tgWebAppData=".length);
}
var hashParams = new URLSearchParams(hash);
var tgFromHash = hashParams.get("tgWebAppData");
if (tgFromHash) return decodeURIComponent(tgFromHash);
} catch (e) { /* ignore */ }
}
var q = window.location.search ? new URLSearchParams(window.location.search).get("tgWebAppData") : null;
if (q) {
@@ -59,9 +80,9 @@
function getInitDataDebug() {
var sdk = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData);
var hash = window.location.hash ? window.location.hash.slice(1) : "";
var hashHasData = hash.indexOf("tgWebAppData=") === 0;
var hashHasData = !!(hash && (getTgWebAppDataFromHash(hash) || new URLSearchParams(hash).get("tgWebAppData")));
var queryHasData = !!(window.location.search && new URLSearchParams(window.location.search).get("tgWebAppData"));
return "SDK: " + (sdk ? "да" : "нет") + ", hash: " + (hashHasData ? hash.length + " симв." : "нет") + ", query: " + (queryHasData ? "да" : "нет");
return "SDK: " + (sdk ? "да" : "нет") + ", hash: " + (hashHasData ? hash.length + " симв." : (hash ? "есть, без tgWebAppData" : "нет")) + ", query: " + (queryHasData ? "да" : "нет");
}
function isLocalhost() {
@@ -69,7 +90,19 @@
return h === "localhost" || h === "127.0.0.1" || h === "";
}
function hasTelegramHashButNoInitData() {
var hash = window.location.hash ? window.location.hash.slice(1) : "";
if (!hash) return false;
try {
var keys = Array.from(new URLSearchParams(hash).keys());
var hasVersion = keys.indexOf("tgWebAppVersion") !== -1;
var hasData = keys.indexOf("tgWebAppData") !== -1 || getTgWebAppDataFromHash(hash);
return hasVersion && !hasData;
} catch (e) { return false; }
}
function showAccessDenied() {
var debugStr = getInitDataDebug();
if (headerEl) headerEl.hidden = true;
if (weekdaysEl) weekdaysEl.hidden = true;
calendarEl.hidden = true;
@@ -78,7 +111,13 @@
errorEl.hidden = true;
accessDeniedEl.hidden = false;
var debugEl = document.getElementById("accessDeniedDebug");
if (debugEl) debugEl.textContent = getInitDataDebug();
if (debugEl) debugEl.textContent = debugStr;
var hintEl = document.getElementById("accessDeniedHint");
if (hintEl) {
hintEl.textContent = hasTelegramHashButNoInitData()
? "Откройте календарь через кнопку меню бота (⋮ или «Календарь»), а не через «Открыть в браузере» или прямую ссылку."
: "Откройте календарь из Telegram.";
}
}
function hideAccessDenied() {
@@ -96,21 +135,19 @@
const headers = {};
if (initData) headers["X-Telegram-Init-Data"] = initData;
var controller = new AbortController();
var timeoutId = setTimeout(function () { controller.abort(); }, 15000);
var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS);
try {
var res = await fetch(url, { headers: headers, signal: controller.signal });
clearTimeout(timeoutId);
if (res.status === 403) {
throw new Error("ACCESS_DENIED");
}
if (res.status === 403) throw new Error("ACCESS_DENIED");
if (!res.ok) throw new Error("Ошибка загрузки");
return res.json();
} catch (e) {
clearTimeout(timeoutId);
if (e.name === "AbortError") {
throw new Error("Не удалось загрузить данные. Проверьте интернет.");
}
throw e;
} finally {
clearTimeout(timeoutId);
}
}
@@ -206,6 +243,25 @@
}
}
/** If allowed (initData or localhost), call onAllowed(); otherwise show access denied. When inside Telegram WebApp but initData is empty, retry once after a short delay (initData may be set asynchronously). */
function requireTelegramOrLocalhost(onAllowed) {
var initData = getInitData();
var isLocal = isLocalhost();
if (initData) { onAllowed(); return; }
if (isLocal) { onAllowed(); return; }
if (window.Telegram && window.Telegram.WebApp) {
setTimeout(function () {
initData = getInitData();
if (initData) { onAllowed(); return; }
showAccessDenied();
loadingEl.classList.add("hidden");
}, RETRY_DELAY_MS);
return;
}
showAccessDenied();
loadingEl.classList.add("hidden");
}
function setNavEnabled(enabled) {
if (prevBtn) prevBtn.disabled = !enabled;
if (nextBtn) nextBtn.disabled = !enabled;
@@ -229,7 +285,7 @@
setNavEnabled(true);
if (window.Telegram && window.Telegram.WebApp && !window._initDataRetried) {
window._initDataRetried = true;
setTimeout(loadMonth, 1200);
setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS);
}
return;
}
@@ -250,5 +306,9 @@
loadMonth();
});
runWhenReady(loadMonth);
runWhenReady(function () {
requireTelegramOrLocalhost(function () {
loadMonth();
});
});
})();

View File

@@ -22,7 +22,7 @@
<div class="error" id="error" hidden></div>
<div class="access-denied" id="accessDenied" hidden>
<p>Доступ запрещён.</p>
<p class="muted">Откройте календарь из Telegram.</p>
<p class="muted" id="accessDeniedHint">Откройте календарь из Telegram.</p>
<p class="debug" id="accessDeniedDebug"></p>
</div>
</div>