Mini Apps
Mini App (Web App) это веб-страница, которая открывается внутри StaticGram и получает данные пользователя, кнопки, хранилище и оплату через JS SDK. Протокол совпадает с Telegram: приложение, написанное для Telegram, работает в StaticGram после замены адреса скрипта.
Что поддерживается
| Возможность | Статус | Где работает |
|---|---|---|
initData с подписью hash (HMAC-SHA256) и signature (Ed25519) | да | сервер |
| MainButton, SecondaryButton, BackButton, SettingsButton, HapticFeedback, тема, fullscreen, геолокация, статус-эмодзи, ярлык на рабочий стол, downloadFile | да | клиент (Android, Desktop, Web; вибрации в Web нет) |
| CloudStorage | да | сервер: до 1024 ключей на пользователя и бота, ключ 1-128 символов A-Z a-z 0-9 _ -, значение до 4096 символов |
| DeviceStorage, SecureStorage | да | клиент, данные хранятся на устройстве |
requestContact | да | ответ подписан так же, как initData |
requestWriteAccess, shareMessage (savePreparedInlineMessage), switchInlineQuery, sendData | да | сервер + клиент |
openInvoice / событие invoiceClosed | да | счета только в звёздах (XTR), см. ниже |
answerWebAppQuery (ответ из Mini App, открытой кнопкой меню, вложением или inline-кнопкой) | да | сервер |
requestChat (WebApp 9.6) | да | Android и Desktop; в Web вызов ничего не делает |
Создание Mini App
Есть три способа открыть веб-приложение, все они совместимы с Telegram:
- Кнопка меню (главное приложение бота). В @BotFather:
/setmenubutton→ бот →https://адрес текст кнопки. Из кода:setChatMenuButtonсMenuButtonWebApp. Это приложение открывается кнопкой слева от поля ввода, из профиля бота и по ссылке?startapp. - Именованное приложение с прямой ссылкой.
/newappв @BotFather (см. ниже). - Кнопки в сообщениях.
web_appвInlineKeyboardButton(поддерживаетanswerWebAppQuery) или вKeyboardButton(поддерживаетTelegram.WebApp.sendData, бот получаетweb_app_data).
@BotFather: /newapp, /myapps, /editapp, /deleteapp
/newapp→ выберите бота кнопкой или пришлите его@username.- Название (до 128 символов), затем описание (до 512 символов,
/emptyчтобы пропустить). - Обложка: фото 640×360, отправленное как фото (не файлом), или
/empty. - Адрес приложения: только
https://, до 512 символов. - Короткое имя (
short_name): 3-30 символовa-z 0-9 _, уникально в пределах бота,mainзарезервировано за кнопкой меню.
В ответ BotFather пришлёт прямую ссылку. /myapps (или /editapp) показывает главное приложение и все Mini Apps бота с кнопками «Название», «Описание», «Адрес (URL)», «Фото» и «Удалить». Отдельно удалить можно через /deleteapp. Удалённое имя можно занять снова. Кнопка «Mini Apps» есть и в карточке бота в /mybots. Лимит: 50 приложений на бота.
Прямые ссылки
| Ссылка | Что открывает |
|---|---|
https://staticgram.top/<bot>/<short_name> | Mini App, созданную через /newapp |
https://staticgram.top/<bot>/<short_name>?startapp=<параметр> | то же, параметр придёт в initDataUnsafe.start_param и в initData как start_param |
https://staticgram.top/<bot>?startapp, …?startapp=<параметр> | главное приложение бота (кнопка меню) |
sg://resolve?domain=<bot>&appname=<short_name>&startapp=<параметр> | то же через схему приложения |
startapp: до 64 символов A-Z a-z 0-9 _ -. Ссылки вида t.me/<bot>/<app> клиенты StaticGram тоже открывают внутри себя, но делиться лучше ссылками на staticgram.top: в браузере они показывают страницу с кнопкой «Open App».
JS SDK
Подключите скрипт до своего кода:
<script src="https://staticgram.top/js/telegram-web-app.js"></script>Это зеркало официального https://telegram.org/js/telegram-web-app.js с одним изменением: openInvoice, openTelegramLink и обработчик ссылок принимают адреса staticgram.top, а не только t.me. С официальным скриптом приложение тоже работает, но ссылки на счёт ему нужно передавать в виде https://t.me/$<slug> (см. «Оплата»).
Параметры запуска сервер передаёт так же, как Telegram, во фрагменте адреса: #tgWebAppData=…&tgWebAppVersion=…&tgWebAppPlatform=…&tgWebAppThemeParams=…. Версия API: 9.6 для Android и Desktop, 9.5 для Web (tgWebAppPlatform=weba). Если приложение использует hash-роутинг, параметры добавляются после ? или & внутри фрагмента, как это делает официальный клиент.
Проверка initData
Никогда не доверяйте initDataUnsafe на сервере: передайте строку Telegram.WebApp.initData на бэкенд и проверьте подпись. Поля: query_id (если приложение открыто кнопкой меню, вложением или inline-кнопкой), user (JSON с id, first_name, last_name, username, language_code, is_premium, allows_write_to_pm), chat_type, chat_instance, chat (для групп и каналов), start_param, auth_date, signature, hash.
Алгоритм тот же, что в Telegram: secret_key = HMAC_SHA256(key="WebAppData", msg=bot_token), data_check_string это все поля кроме hash, отсортированные по имени и соединённые \n в виде key=value; hash = hex(HMAC_SHA256(key=secret_key, msg=data_check_string)). Также проверяйте auth_date, чтобы не принимать старые данные.
import hashlib, hmac, json, time
from urllib.parse import parse_qsl
def check_init_data(init_data: str, bot_token: str, max_age: int = 86400) -> dict:
data = dict(parse_qsl(init_data, keep_blank_values=True, strict_parsing=True))
received = data.pop("hash")
check = "\n".join(f"{k}={v}" for k, v in sorted(data.items()))
secret = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
expected = hmac.new(secret, check.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received):
raise ValueError("bad hash")
if time.time() - int(data["auth_date"]) > max_age:
raise ValueError("init data is too old")
data["user"] = json.loads(data["user"])
return dataimport crypto from "node:crypto";
export function checkInitData(initData, botToken, maxAge = 86400) {
const params = new URLSearchParams(initData);
const received = params.get("hash");
params.delete("hash");
const check = [...params.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join("\n");
const secret = crypto.createHmac("sha256", "WebAppData").update(botToken).digest();
const expected = crypto.createHmac("sha256", secret).update(check).digest("hex");
if (!received || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
throw new Error("bad hash");
}
if (Date.now() / 1000 - Number(params.get("auth_date")) > maxAge) {
throw new Error("init data is too old");
}
return { ...Object.fromEntries(params), user: JSON.parse(params.get("user")) };
}Библиотеки, которые проверяют initData по алгоритму Telegram (например, aiogram.utils.web_app.safe_parse_webapp_init_data, @telegram-apps/init-data-node), работают без изменений: они проверяют hash токеном бота.
Проверка без токена (signature, Ed25519)
Сторонний сервис, у которого нет токена бота, может проверить поле signature публичным ключом сервера StaticGram:
8a46bf36bbf37596cf69ad66305bf0fc1f2177ffd50063d0f45d364e4726278fПодписывается строка <bot_id>:WebAppData\n<data_check_string>, где data_check_string собирается из всех полей, кроме hash и signature; signature закодирована в base64url без =. Это тот же формат, что у Telegram, отличается только ключ (ключи Telegram для StaticGram не подходят).
import base64
from urllib.parse import parse_qsl
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
STATICGRAM_PUBLIC_KEY = bytes.fromhex("8a46bf36bbf37596cf69ad66305bf0fc1f2177ffd50063d0f45d364e4726278f")
def check_signature(init_data: str, bot_id: int) -> bool:
data = dict(parse_qsl(init_data, keep_blank_values=True))
sig = data.pop("signature")
data.pop("hash", None)
check = f"{bot_id}:WebAppData\n" + "\n".join(f"{k}={v}" for k, v in sorted(data.items()))
try:
Ed25519PublicKey.from_public_bytes(STATICGRAM_PUBLIC_KEY).verify(
base64.urlsafe_b64decode(sig + "=" * (-len(sig) % 4)), check.encode())
return True
except Exception:
return Falseimport crypto from "node:crypto";
const PUBLIC_KEY = crypto.createPublicKey({
// SPKI-обёртка для сырого 32-байтного ключа Ed25519
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"),
Buffer.from("8a46bf36bbf37596cf69ad66305bf0fc1f2177ffd50063d0f45d364e4726278f", "hex")]),
format: "der", type: "spki",
});
export function checkSignature(initData, botId) {
const params = new URLSearchParams(initData);
const sig = Buffer.from(params.get("signature") ?? "", "base64url");
params.delete("signature");
params.delete("hash");
const check = `${botId}:WebAppData\n` + [...params.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join("\n");
return crypto.verify(null, Buffer.from(check), PUBLIC_KEY, sig);
}Оплата в Mini App
- Бэкенд создаёт счёт в звёздах:
createInvoiceLinkсcurrency: "XTR". Ответ:https://staticgram.top/invoice/<slug>. - Приложение открывает его:
Telegram.WebApp.openInvoice(link, status => …). Клиент показывает форму оплаты звёздами поверх приложения. - Бот получает
pre_checkout_queryи отвечаетanswerPreCheckoutQuery, после оплаты приходитsuccessful_payment(см. Оплата звёздами). - Приложение получает
invoiceClosedсо статусомpaid,cancelled,failedилиpending.
const link = await fetch("/api/invoice").then(r => r.text()); // createInvoiceLink на бэкенде
Telegram.WebApp.openInvoice(link, (status) => {
if (status === "paid") showThanks();
});Он принимает в openInvoice только ссылки t.me. Если вы подключили скрипт с telegram.org, замените адрес: https://staticgram.top/invoice/AbC → https://t.me/$AbC. Клиент передаёт серверу только slug, оплата пройдёт в StaticGram.
Ответ в чат и shareMessage
answerWebAppQuery: если приложение открыто кнопкой меню, вложением или inline-кнопкой, вinitDataестьquery_id. Бэкенд отправляет по немуInlineQueryResult, и сообщение уходит в чат от имени пользователя через бота.savePreparedInlineMessage+Telegram.WebApp.shareMessage(id): пользователь сам выбирает, в какой чат отправить подготовленное сообщение.Telegram.WebApp.sendData(data)работает только для приложений с кнопкиweb_appобычной клавиатуры. Бот получает сообщение с полемweb_app_data.