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. Edit `.env` and set `BOT_TOKEN` to the token from BotFather.
5. **Miniapp access (calendar)** 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** 6. **Optional env**
- `DATABASE_URL` DB connection (default: `sqlite:///data/duty_teller.db`). - `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.session import session_scope
from db.repository import get_duties from db.repository import get_duties
from db.schemas import DutyWithUser 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__) 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: 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).
@@ -110,12 +120,12 @@ def list_duties(
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")
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None 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: if username is None:
log.warning( log.warning("duties: initData validation failed: %s", auth_reason)
"duties: initData validation failed (invalid signature or no username)" raise HTTPException(status_code=403, detail=_auth_error_detail(auth_reason))
)
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")
raise HTTPException(status_code=403, detail="Доступ запрещён") raise HTTPException(status_code=403, detail="Доступ запрещён")

View File

@@ -15,17 +15,23 @@ def validate_init_data(
bot_token: str, bot_token: str,
max_age_seconds: int | None = None, max_age_seconds: int | None = None,
) -> str | None: ) -> str | None:
""" """Validate initData and return username; see validate_init_data_with_reason for failure reason."""
Validate initData signature and return the Telegram username (lowercase, no @). username, _ = validate_init_data_with_reason(init_data, bot_token, max_age_seconds)
Returns None if data is invalid, forged, or user has no username. 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: if not init_data or not bot_token:
return None return (None, "empty")
init_data = init_data.strip() init_data = init_data.strip()
# Parse preserving raw values for HMAC (key -> raw value)
params = {} params = {}
for part in init_data.split("&"): for part in init_data.split("&"):
if "=" not in part: if "=" not in part:
@@ -36,46 +42,41 @@ def validate_init_data(
params[key] = value params[key] = value
hash_val = params.pop("hash", None) hash_val = params.pop("hash", None)
if not hash_val: if not hash_val:
return None return (None, "no_hash")
# Build data-check string: keys sorted, format key=value per line (raw values)
data_pairs = sorted(params.items()) data_pairs = sorted(params.items())
data_string = "\n".join(f"{k}={v}" for k, v in data_pairs) 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( secret_key = hmac.new(
b"WebAppData", b"WebAppData",
msg=bot_token.encode(), msg=bot_token.encode(),
digestmod=hashlib.sha256, digestmod=hashlib.sha256,
).digest() ).digest()
# computed = HMAC-SHA256(key=secret_key, msg=data_string.encode()).hexdigest()
computed = hmac.new( computed = hmac.new(
secret_key, secret_key,
msg=data_string.encode(), msg=data_string.encode(),
digestmod=hashlib.sha256, digestmod=hashlib.sha256,
).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, "hash_mismatch")
# Optional replay protection: reject initData older than max_age_seconds
if max_age_seconds is not None and max_age_seconds > 0: if max_age_seconds is not None and max_age_seconds > 0:
auth_date_raw = params.get("auth_date") auth_date_raw = params.get("auth_date")
if not auth_date_raw: if not auth_date_raw:
return None return (None, "auth_date_expired")
try: try:
auth_date = int(float(auth_date_raw)) auth_date = int(float(auth_date_raw))
except (ValueError, TypeError): except (ValueError, TypeError):
return None return (None, "auth_date_expired")
if time.time() - auth_date > max_age_seconds: if time.time() - auth_date > max_age_seconds:
return None return (None, "auth_date_expired")
# 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:
return None return (None, "no_user")
try: try:
user = json.loads(unquote(user_raw)) user = json.loads(unquote(user_raw))
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return None return (None, "user_invalid")
if not isinstance(user, dict): if not isinstance(user, dict):
return None return (None, "user_invalid")
username = user.get("username") username = user.get("username")
if not username or not isinstance(username, str): if not username or not isinstance(username, str):
return None return (None, "no_username")
return username.strip().lstrip("@").lower() 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") 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): 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( r = client.get(
"/api/duties", "/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"}, params={"from": "2025-01-01", "to": "2025-01-31"},
headers={"X-Telegram-Init-Data": "some=data&hash=abc"}, headers={"X-Telegram-Init-Data": "some=data&hash=abc"},
) )
assert r.status_code == 403 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") @patch("api.app.config.can_access_miniapp")
def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client): 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 mock_can_access.return_value = False
with patch("api.app._fetch_duties_response") as mock_fetch: with patch("api.app._fetch_duties_response") as mock_fetch:
r = client.get( 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() 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") @patch("api.app.config.can_access_miniapp")
def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client): 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 mock_can_access.return_value = True
with patch("api.app._fetch_duties_response") as mock_fetch: with patch("api.app._fetch_duties_response") as mock_fetch:
mock_fetch.return_value = [ 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): 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_token = "123:ABC"
test_username = "e2euser" test_username = "e2euser"
monkeypatch.setattr(config, "BOT_TOKEN", test_token) monkeypatch.setattr(config, "BOT_TOKEN", test_token)

View File

@@ -1,7 +1,6 @@
"""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 dotenv import load_dotenv from dotenv import load_dotenv
@@ -16,7 +15,6 @@ if not BOT_TOKEN:
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("/")
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080")) 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. # 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()

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 contextlib import contextmanager
from typing import Generator from typing import Generator

View File

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

View File

@@ -8,7 +8,7 @@ from telegram.ext import ContextTypes
logger = logging.getLogger(__name__) 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") logger.exception("Exception while handling an update")
if isinstance(update, Update) and update.effective_message: if isinstance(update, Update) and update.effective_message:
await update.effective_message.reply_text("Произошла ошибка. Попробуйте позже.") 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.""" """Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint."""
import asyncio import asyncio
import json
import logging import logging
import threading import threading
import urllib.request
import config import config
from telegram.ext import ApplicationBuilder from telegram.ext import ApplicationBuilder
@@ -16,6 +18,36 @@ logging.basicConfig(
logger = logging.getLogger(__name__) 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: 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
@@ -29,6 +61,7 @@ def _run_uvicorn(web_app, port: int) -> None:
def main() -> None: def main() -> None:
_set_default_menu_button_webapp()
app = ApplicationBuilder().token(config.BOT_TOKEN).build() app = ApplicationBuilder().token(config.BOT_TOKEN).build()
register_handlers(app) register_handlers(app)

View File

@@ -1,4 +1,8 @@
(function () { (function () {
const FETCH_TIMEOUT_MS = 15000;
const RETRY_DELAY_MS = 800;
const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
const MONTHS = [ const MONTHS = [
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"
@@ -34,16 +38,33 @@
return new Date(d.getFullYear(), d.getMonth(), diff); 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() { function getInitData() {
var fromSdk = (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || ""; var fromSdk = (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || "";
if (fromSdk) return fromSdk; if (fromSdk) return fromSdk;
var hash = window.location.hash ? window.location.hash.slice(1) : ""; 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 { try {
return decodeURIComponent(hash.substring("tgWebAppData=".length)); var hashParams = new URLSearchParams(hash);
} catch (e) { var tgFromHash = hashParams.get("tgWebAppData");
return hash.substring("tgWebAppData=".length); if (tgFromHash) return decodeURIComponent(tgFromHash);
} } catch (e) { /* ignore */ }
} }
var q = window.location.search ? new URLSearchParams(window.location.search).get("tgWebAppData") : null; var q = window.location.search ? new URLSearchParams(window.location.search).get("tgWebAppData") : null;
if (q) { if (q) {
@@ -59,9 +80,9 @@
function getInitDataDebug() { function getInitDataDebug() {
var sdk = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData); var sdk = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData);
var hash = window.location.hash ? window.location.hash.slice(1) : ""; 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")); 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() { function isLocalhost() {
@@ -69,7 +90,19 @@
return h === "localhost" || h === "127.0.0.1" || h === ""; 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() { function showAccessDenied() {
var debugStr = getInitDataDebug();
if (headerEl) headerEl.hidden = true; if (headerEl) headerEl.hidden = true;
if (weekdaysEl) weekdaysEl.hidden = true; if (weekdaysEl) weekdaysEl.hidden = true;
calendarEl.hidden = true; calendarEl.hidden = true;
@@ -78,7 +111,13 @@
errorEl.hidden = true; errorEl.hidden = true;
accessDeniedEl.hidden = false; accessDeniedEl.hidden = false;
var debugEl = document.getElementById("accessDeniedDebug"); 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() { function hideAccessDenied() {
@@ -96,21 +135,19 @@
const headers = {}; const headers = {};
if (initData) headers["X-Telegram-Init-Data"] = initData; if (initData) headers["X-Telegram-Init-Data"] = initData;
var controller = new AbortController(); var controller = new AbortController();
var timeoutId = setTimeout(function () { controller.abort(); }, 15000); var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS);
try { try {
var res = await fetch(url, { headers: headers, signal: controller.signal }); 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("Ошибка загрузки"); if (!res.ok) throw new Error("Ошибка загрузки");
return res.json(); return res.json();
} catch (e) { } catch (e) {
clearTimeout(timeoutId);
if (e.name === "AbortError") { if (e.name === "AbortError") {
throw new Error("Не удалось загрузить данные. Проверьте интернет."); throw new Error("Не удалось загрузить данные. Проверьте интернет.");
} }
throw e; 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) { function setNavEnabled(enabled) {
if (prevBtn) prevBtn.disabled = !enabled; if (prevBtn) prevBtn.disabled = !enabled;
if (nextBtn) nextBtn.disabled = !enabled; if (nextBtn) nextBtn.disabled = !enabled;
@@ -229,7 +285,7 @@
setNavEnabled(true); setNavEnabled(true);
if (window.Telegram && window.Telegram.WebApp && !window._initDataRetried) { if (window.Telegram && window.Telegram.WebApp && !window._initDataRetried) {
window._initDataRetried = true; window._initDataRetried = true;
setTimeout(loadMonth, 1200); setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS);
} }
return; return;
} }
@@ -250,5 +306,9 @@
loadMonth(); loadMonth();
}); });
runWhenReady(loadMonth); runWhenReady(function () {
requireTelegramOrLocalhost(function () {
loadMonth();
});
});
})(); })();

View File

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