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