Files
duty-teller/api/telegram_auth.py
Nikolay Tatarinov dd960dc5cc 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.
2026-02-17 19:08:14 +03:00

83 lines
2.8 KiB
Python

"""Validate Telegram Web App initData and extract user username."""
import hashlib
import hmac
import json
import time
from urllib.parse import unquote
# 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.
def validate_init_data(
init_data: str,
bot_token: str,
max_age_seconds: int | None = None,
) -> str | None:
"""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
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, "empty")
init_data = init_data.strip()
params = {}
for part in init_data.split("&"):
if "=" not in part:
continue
key, _, value = part.partition("=")
if not key:
continue
params[key] = value
hash_val = params.pop("hash", None)
if not hash_val:
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.new(
b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
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, "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, "auth_date_expired")
try:
auth_date = int(float(auth_date_raw))
except (ValueError, TypeError):
return (None, "auth_date_expired")
if time.time() - auth_date > max_age_seconds:
return (None, "auth_date_expired")
user_raw = params.get("user")
if not user_raw:
return (None, "no_user")
try:
user = json.loads(unquote(user_raw))
except (json.JSONDecodeError, TypeError):
return (None, "user_invalid")
if not isinstance(user, dict):
return (None, "user_invalid")
username = user.get("username")
if not username or not isinstance(username, str):
return (None, "no_username")
return (username.strip().lstrip("@").lower(), "ok")