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:
@@ -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`).
|
||||
|
||||
22
api/app.py
22
api/app.py
@@ -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="Доступ запрещён")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
33
main.py
@@ -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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user