Compare commits

..
18 Commits
Author SHA1 Message Date
3252a8 e1223f8c57 fix: connect button disappeard in web app 2026-05-01 22:48:27 +03:00
3252a8 81f5168726 chore: improve telegram bot menu navigation and referrals 2026-05-01 22:16:00 +03:00
3252a8 4de730bf5a feat: add Telegram OAuth login flow 2026-04-30 19:50:17 +03:00
3252a8 d15b1be3b9 fix: click on another button when switching lang 2026-04-30 14:05:01 +03:00
3252a8 4d0241a0ea fix: remove logo bg in email templates 2026-04-30 10:29:01 +03:00
3252a8 2c1e86863d feat: support hwid in web app 2026-04-30 09:25:43 +03:00
3252a8 16b11327ee fix: traffic strategy 2026-04-30 07:57:14 +03:00
3252a8 3d5998b8fb feat: support traffic and trial flow in web app 2026-04-30 07:34:29 +03:00
3252a8 2df4d2d770 feat: tune webapp visual 2026-04-30 00:06:31 +03:00
3252a8 4ce78f277e feat: migrate to svelte initial 2026-04-29 11:10:12 +03:00
3252a8 d7d5c2b1ef refactor: remove local storing tg js 2026-04-28 23:33:24 +03:00
3252a8 aabc0e312d feat: log email and tg linking 2026-04-28 13:54:05 +03:00
3252a8 d15d58df36 docs: tariffs documentation 2026-04-28 10:47:59 +03:00
3252a8 21f2ac4534 fix: show red traffic bar in web app when sub is expired 2026-04-28 10:32:56 +03:00
3252a8 dd0e6e75af feat: add magic login link to email with login codes 2026-04-28 09:50:17 +03:00
3252a8 0251f939d7 feat: log users when reg via email 2026-04-27 23:29:28 +03:00
3252a8 a00bc0f345 feat: design for emails 2026-04-27 23:05:14 +03:00
3252a8 ae4ce43e1c chore: tune default web app logo 2026-04-27 22:28:34 +03:00
53 changed files with 8758 additions and 8247 deletions
+1
View File
@@ -28,6 +28,7 @@ tmp/
# WebApp build artifacts (regenerated inside Docker)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
# Byte-compiled / optimized / DLL files
+6 -2
View File
@@ -37,14 +37,18 @@ TRUSTED_PROXIES=127.0.0.1,::1 #
WEBAPP_ENABLED=True # Run Mini App HTTP server
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="Моя подписка" # Mini App title
WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL; shown in the header and login screen, leave empty to hide
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
SMTP_HOST=smtp-relay.brevo.com # SMTP server
+5
View File
@@ -12,7 +12,10 @@ node_modules/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
tmp
.claude
# Игнорировать кэш Python
__pycache__/
@@ -27,3 +30,5 @@ __pycache__/
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
config/tariffs.json
docker-compose-dev.yml
+4
View File
@@ -16,6 +16,7 @@ COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY bot/app/web/frontend ./bot/app/web/frontend
COPY bot/app/web/templates ./bot/app/web/templates
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
@@ -36,9 +37,12 @@ COPY . .
# Replace template assets with freshly built ones
RUN rm -f bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.js \
bot/app/web/templates/subscription_webapp.min.*.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.css
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
bot/app/web/templates/subscription_webapp.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
bot/app/web/templates/
+26 -23
View File
@@ -1,6 +1,6 @@
# Remnawave Minishop
Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для автоматизации продажи и управления подписками панели **Remnawave**. Бот закрывает сценарий покупки, продления и работы с поддержкой прямо в чате, а Web App в едином интерфейсе показывает ссылку подключения, остаток времени, трафик, оплату и устройства, поддерживая вход через Telegram Mini Apps `initData`, Telegram Login Widget и одноразовый код по email. Под капотом — интеграция с API Remnawave для управления пользователями и подписками и набор платёжных шлюзов для приёма платежей.
Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для автоматизации продажи и управления подписками панели **Remnawave**. Бот закрывает сценарий покупки, продления и работы с поддержкой прямо в чате, а Web App в едином интерфейсе показывает ссылку подключения, остаток времени, трафик, оплату и устройства, поддерживая вход через Telegram Mini Apps `initData`, новый Telegram OAuth / OpenID Connect Login и одноразовый код по email. Под капотом — интеграция с API Remnawave для управления пользователями и подписками и набор платёжных шлюзов для приёма платежей.
> 🍴 **Это глубоко переработанный форк [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop).** Здесь добавлены полноценный Web App / Mini App, вход по email и многое другое. Возможна миграция.
@@ -78,6 +78,9 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` |
| `TELEGRAM_OAUTH_CLIENT_ID` | Client ID для нового Telegram OAuth / OpenID Connect Login из BotFather. Если пусто, используется числовой ID из `BOT_TOKEN`. | `1234567890` |
| `TELEGRAM_OAUTH_CLIENT_SECRET` | Client Secret из BotFather для Telegram OAuth Authorization Code Flow. | `tg_oauth_secret` |
| `TELEGRAM_OAUTH_REQUEST_ACCESS` | Дополнительные разрешения Telegram Login через запятую: `write`, `phone`. Пустое значение запрашивает только OpenID profile. | `write` |
| `SMTP_HOST` | SMTP-сервер для кодов входа по email. Для Brevo: `smtp-relay.brevo.com`. | `smtp-relay.brevo.com` |
| `SMTP_PORT` | SMTP-порт. Для Brevo обычно используется 587 с STARTTLS. | `587` |
| `SMTP_FALLBACK_PORTS` | Дополнительные SMTP-порты через запятую. Пробуются после `SMTP_PORT`; порт `465` автоматически используется через SSL. Для Brevo удобно оставить `2525,465`. | `2525,465` |
@@ -140,13 +143,11 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
</details>
<details>
<summary><b>Настройки подписок</b></summary>
<summary><b>Настройки тарифов</b></summary>
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
- `1_MONTH_ENABLED`: `true` или `false`
- `RUB_PRICE_1_MONTH`: Цена в рублях
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
Бот умеет продавать **подписку на срок** (1/3/6/12 мес.) или **пакеты трафика** (`TRAFFIC_PACKAGES=10:199,50:799`). Эти режимы взаимоисключающие — наличие непустой `TRAFFIC_PACKAGES` (или `STARS_TRAFFIC_PACKAGES`) автоматически переключает бот в режим продажи трафика.
Полное описание обоих режимов, переменных, что происходит при покупке, как ведут себя автопродление, реф-бонусы и триал — вынесено в [docs/tariffs.md](docs/tariffs.md).
</details>
<details>
@@ -237,7 +238,7 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
### Настройка Web App / Mini App
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту.
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Telegram пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, используется новый Telegram OAuth / OpenID Connect Authorization Code Flow с PKCE, callback `/auth/telegram/callback`, `nonce` и серверной проверкой `id_token` по JWKS Telegram. Старый Login Widget больше не используется в UI. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту.
1. Укажите в `.env` публичный URL Web App и порт:
@@ -249,6 +250,9 @@ Web App запускается в том же контейнере, что и б
WEBAPP_TITLE="Моя подписка"
WEBAPP_PRIMARY_COLOR="#00fe7a"
WEBAPP_LOGO_URL=
TELEGRAM_OAUTH_CLIENT_ID=<client-id-из-botfather>
TELEGRAM_OAUTH_CLIENT_SECRET=<client-secret-из-botfather>
TELEGRAM_OAUTH_REQUEST_ACCESS=write
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_FALLBACK_PORTS=2525,465
@@ -293,7 +297,17 @@ Web App запускается в том же контейнере, что и б
}
```
4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`. Этот же домен используется и Telegram Login Widget.
4. В BotFather настройте бота, Mini App и Telegram OAuth Login:
- `@BotFather` → `/mybots` → выберите бота.
- **Bot Settings → Domain**: укажите домен без протокола и пути, например `app.domain.com`.
- **Bot Settings → Mini Apps**: задайте URL Mini App, например `https://app.domain.com/`.
- **Bot Settings → Web Login**: если BotFather показывает кнопку `Switch to OpenID Connect Login`, нажмите ее.
- **Bot Settings → Web Login**: скопируйте Client ID и Client Secret в `TELEGRAM_OAUTH_CLIENT_ID` и `TELEGRAM_OAUTH_CLIENT_SECRET`.
- **Web Login → Allowed URLs**: добавьте:
`https://app.domain.com/`
`https://app.domain.com/auth/telegram/callback`
- `TELEGRAM_OAUTH_REQUEST_ACCESS=write` разрешает боту написать пользователю после логина. Если дополнительные разрешения не нужны, оставьте переменную пустой.
5. Перезапустите контейнер:
@@ -301,23 +315,12 @@ Web App запускается в том же контейнере, что и б
docker compose up -d --build
```
После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Login Widget. После привязки вход работает обоими способами.
После этого кнопка «Личный кабинет» в меню бота откроет Web App. Рядом доступна кнопка «Бот-меню» для открытия расширенного интерфейса в чате без команды `/tg`, но основной сценарий управления подпиской удобнее проходить в личном кабинете. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Telegram OAuth Login. После привязки вход работает обоими способами.
Реферальные ссылки доступны в двух форматах: Telegram deep-link `https://t.me/<bot>?start=ref_u<code>` и Web App ссылка с query-параметром `ref=u<code>`. Web App учитывает `ref`, `start`, `start_param` и Telegram Mini Apps `start_param`, сохраняет найденный параметр до авторизации и передаёт его в Telegram OAuth и email-вход, чтобы регистрация корректно привязалась к пригласившему.
Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов.
Для настройки внешнего вида без запуска бота можно открыть файл `bot/app/web/templates/subscription_webapp.html` напрямую в браузере. Рядом с ним лежат `telegram-web-app.js`, `telegram-widget.js`, `subscription_webapp.css` и `subscription_webapp.js`, поэтому локальный предпросмотр работает без сервера и без внешнего CDN. При отдаче страницы через Web App сервер dev-mock автоматически вырезается и не попадает пользователям.
Локальная копия Telegram Web App SDK хранится в `bot/app/web/templates/telegram-web-app.js`, а локальная копия Telegram Login Widget - в `bot/app/web/templates/telegram-widget.js`. В контейнере обе копии автоматически обновляются при старте Web App и затем раз в 24 часа; если источник временно недоступен, используется уже сохраненная версия. У локального `telegram-widget.js` есть минимальная нормализация, чтобы при загрузке с вашего домена виджет все равно открывал iframe на Telegram-origin, а не на `/embed/...` вашего сайта. Для ручного обновления можно запустить команды:
```bash
python scripts/update_telegram_web_app_js.py
python scripts/update_telegram_widget_js.py
```
При необходимости оба скрипта принимают `--source-url` и `--target`, если нужно скачать файл в другое место или проверить альтернативный источник.
## Подробная инструкция для развертывания на сервере с панелью Remnawave
### 1. Клонирование репозитория
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
<script>
import {
ArrowRight,
CheckCircle2,
Circle,
Copy,
Crown,
Database,
Download,
Gift,
Globe2,
LockKeyhole,
Mail,
RefreshCw,
Repeat2,
Send,
Ticket,
UserRound,
Zap,
} from "lucide-svelte";
import Button from "./lib/components/ui/button.svelte";
import Card from "./lib/components/ui/card.svelte";
import BackTitle from "./preview/BackTitle.svelte";
import PhoneFrame from "./preview/PhoneFrame.svelte";
import PreviewMethods from "./preview/PreviewMethods.svelte";
import PreviewNav from "./preview/PreviewNav.svelte";
export let config = {};
export let mockData = {};
const title = config.title || "/minishop";
const logoEmoji = config.logoEmoji || "🫥";
const plans = mockData.plans || [];
const sub = mockData.subscription || {};
const methods = mockData.payment_methods || [];
const user = mockData.user || {};
const tariffs = [
["subscription", "Подписка", "Безлимитный трафик", "Идеально для постоянного использования", Zap],
["traffic", "Трафик", "Пакеты гигабайт", "Платите только за нужный объем", Database],
["premium", "Премиум", "Максимальная скорость", "Приоритетные серверы и поддержка", Crown],
];
const traffic = [
[20, 290],
[50, 590],
[100, 990],
[300, 2190],
];
const settingsRows = [
[Globe2, "Язык интерфейса", "Русский"],
[Send, "Привязка Telegram", user.telegram_linked ? `@${user.username || "username"}` : "Не привязан"],
[Mail, "Привязка почты", user.email || "Не привязана"],
[UserRound, "Выйти", "Завершить сессию"],
];
const previewTelegramName = user.first_name || (user.username ? `@${user.username}` : "Telegram не привязан");
const previewEmail = user.email || "Почта не привязана";
const previewTelegramId = user.telegram_id ? `TG ID ${user.telegram_id}` : "TG ID не привязан";
const previewAvatar = user.telegram_photo_url || "";
function money(value) {
return `${value} ₽`;
}
</script>
<div class="preview-board" style={`--accent: ${config.primaryColor || "#00fe7a"};`}>
<PhoneFrame number="1" label="Главный экран">
<main class="home-layout">
<div class="login-brand home-brand">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
<h1>{title}</h1>
</div>
<div class="home-bottom">
<Card class="status-card">
<div class="sub-status">
<CheckCircle2 size={23} />
<div><h2>Подписка активна</h2><p>до {sub.end_date_text}</p></div>
</div>
</Card>
<Card>
<div class="traffic-top"><span>Использовано трафика</span><strong>{sub.traffic_used} из {sub.traffic_limit}</strong></div>
<div class="progress"><span style="width: 18%"></span></div>
<div class="traffic-percent">18%</div>
</Card>
<div class="action-stack">
<Button class="wide"><Download size={17} />Установить и настроить</Button>
<Button variant="secondary" class="wide"><RefreshCw size={17} />Продлить</Button>
<Button variant="secondary" class="wide"><Repeat2 size={17} />Сменить тариф</Button>
</div>
</div>
<PreviewNav active="home" />
</main>
</PhoneFrame>
<PhoneFrame number="2" label="Выбор тарифа">
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
<div class="tariff-list">
{#each tariffs as tariff, index}
<div class:active={index === 0} class="select-card">
<span class="select-icon"><svelte:component this={tariff[4]} size={24} /></span>
<span><strong>{tariff[1]}</strong><small>{tariff[2]}</small><em>{tariff[3]}</em></span>
{#if index === 0}<CheckCircle2 size={21} />{:else}<Circle size={21} />{/if}
</div>
{/each}
</div>
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
</PhoneFrame>
<PhoneFrame number="3" label="Оплата тарифа — подписка" wide>
<BackTitle title="Подписка" subtitle="Выберите срок подписки" />
<div class="period-grid">
{#each plans as plan, index}
<div class:active={index === 1} class="period-card">
<strong>{plan.title}</strong><span>{money(plan.price)}</span><small>{money(Math.round(plan.price / plan.months))}/мес</small>
{#if index === 1}<CheckCircle2 size={18} />{/if}
</div>
{/each}
</div>
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>790 ₽</strong></Card>
<PreviewMethods {methods} />
<Button class="wide bottom-action">Оплатить 790 ₽ <LockKeyhole size={16} /></Button>
</PhoneFrame>
<PhoneFrame number="4" label="Оплата тарифа — трафик" wide>
<BackTitle title="Трафик" subtitle="Выберите пакет трафика" />
<div class="period-grid">
{#each traffic as pack, index}
<div class:active={index === 2} class="period-card">
<strong>{pack[0]} ГБ</strong><span>{money(pack[1])}</span><small>{money(Math.round(pack[1] / pack[0]))}/ГБ</small>
{#if index === 2}<CheckCircle2 size={18} />{/if}
</div>
{/each}
</div>
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>990 ₽</strong></Card>
<PreviewMethods {methods} />
<Button class="wide bottom-action">Оплатить 990 ₽ <LockKeyhole size={16} /></Button>
</PhoneFrame>
<PhoneFrame number="5" label="Смена тарифа">
<BackTitle title="Смена тарифа" subtitle="Остаток 12 дней будет пересчитан" />
<div class="tariff-list compact">
<div class="select-card"><span><strong>Подписка</strong><small>Безлимитный трафик</small></span><em>Доплата 190 ₽</em><Circle size={20} /></div>
<div class="select-card active"><span><strong>Трафик</strong><small>Пакеты гигабайт</small></span><em>Доплата не требуется</em><CheckCircle2 size={20} /></div>
<div class="select-card"><span><strong>Премиум</strong><small>Максимальная скорость</small></span><em>Доплата 390 ₽</em><Circle size={20} /></div>
</div>
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
<div class="preview-modal">
<Repeat2 size={30} />
<strong>Сменить тариф без доплаты?</strong>
<p>Остаток 12 дней будет пересчитан по новому тарифу.</p>
<Button>Да, сменить</Button>
<Button variant="secondary">Отмена</Button>
</div>
</PhoneFrame>
<PhoneFrame number="6" label="Пригласить друга">
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
<Card>
<div class="card-label">Ваша реферальная ссылка</div>
<div class="copy-row"><code>https://minishop.app/ref/ABCD1234</code><Button>Копировать <Copy size={16} /></Button></div>
</Card>
<Card class="bonus-card">
<Gift size={42} /><div><span>Ваш бонус</span><strong>+7 дней за каждого друга</strong><p>Друг получит +3 дня к подписке.</p></div>
</Card>
<Button variant="outline" class="wide"><Ticket size={18} />Активировать промокод</Button>
</PhoneFrame>
<PhoneFrame number="7" label="Настройки">
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
<Card class="settings-profile">
<div class="settings-avatar">
{#if previewAvatar}
<img src={previewAvatar} alt="Аватар пользователя" />
{:else}
<UserRound size={27} />
{/if}
</div>
<div class="settings-profile-meta">
<strong>{previewTelegramName}</strong>
<small>{previewEmail}</small>
<small>{previewTelegramId}</small>
</div>
</Card> <div class="settings-list">
{#each settingsRows as row}
<div class="settings-row">
<svelte:component this={row[0]} size={20} />
<span><strong>{row[1]}</strong><small>{row[2]}</small></span>
<ArrowRight size={16} />
</div>
{/each}
</div>
</PhoneFrame>
<PhoneFrame number="8" label="Логин" wide>
<div class="login-brand small">
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
<h1>{title}</h1><p>Войдите в свой аккаунт</p>
</div>
<Card class="auth-card">
<div class="field-label">Вход по email</div>
<div class="auth-email-stack"><div class="input muted">Email</div><Button class="wide"><Mail size={17} />Войти по почте</Button></div>
<div class="or-line"><span></span>или<span></span></div>
<Button variant="telegram" class="wide telegram-login-button">
<span class="telegram-login-text"><Send size={17} />Войти через телеграм</span>
</Button>
</Card>
</PhoneFrame>
<PhoneFrame number="9" label="Подтверждение по коду" wide>
<BackTitle title="Подтверждение по email" subtitle="Мы отправили код на user@example.com" />
<div class="otp-slots static">{#each [1, 2, 3, 4, 5, 6] as digit}<span>{digit}</span>{/each}</div>
<Button class="wide bottom-action">Подтвердить</Button>
<button class="link-button"><RefreshCw size={15} />Отправить код повторно (00:45)</button>
</PhoneFrame>
</div>
@@ -0,0 +1,38 @@
<script>
import { cn } from "../../utils.js";
export let type = "button";
export let variant = "default";
export let size = "default";
export let disabled = false;
export let href = "";
export let onclick = undefined;
let className = "";
export { className as class };
const variants = {
default: "btn btn-primary",
secondary: "btn btn-secondary",
outline: "btn btn-outline",
ghost: "btn btn-ghost",
telegram: "btn btn-telegram",
icon: "btn btn-icon",
};
const sizes = {
default: "",
sm: "btn-sm",
lg: "btn-lg",
icon: "btn-square",
};
</script>
{#if href}
<a class={cn(variants[variant], sizes[size], className)} {href} on:click={onclick} {...$$restProps}>
<slot />
</a>
{:else}
<button class={cn(variants[variant], sizes[size], className)} {type} {disabled} on:click={onclick} {...$$restProps}>
<slot />
</button>
{/if}
@@ -0,0 +1,12 @@
<script>
import { cn } from "../../utils.js";
export let active = false;
export let compact = false;
let className = "";
export { className as class };
</script>
<section class={cn("card", active && "card-active", compact && "card-compact", className)}>
<slot />
</section>
@@ -0,0 +1,31 @@
<script>
import { X } from "lucide-svelte";
import { cn } from "../../utils.js";
import Button from "./button.svelte";
export let open = false;
export let title = "";
export let description = "";
export let closeLabel = "Close";
export let onclose = () => {};
let className = "";
export { className as class };
</script>
{#if open}
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
<button class="dialog-backdrop" type="button" aria-label={closeLabel} on:click={onclose}></button>
<section class={cn("dialog-card", className)}>
<div class="dialog-head">
<div>
{#if title}<h2>{title}</h2>{/if}
{#if description}<p>{description}</p>{/if}
</div>
<Button variant="icon" size="icon" onclick={onclose} aria-label={closeLabel}>
<X size={18} />
</Button>
</div>
<slot />
</section>
</div>
{/if}
@@ -0,0 +1,29 @@
<script>
import { cn } from "../../utils.js";
export let value = "";
export let type = "text";
export let placeholder = "";
export let inputmode = undefined;
export let maxlength = undefined;
export let autocomplete = undefined;
export let disabled = false;
let className = "";
export { className as class };
</script>
<input
bind:value
class={cn("input", className)}
on:keydown
on:input
on:focus
on:blur
{type}
{placeholder}
{inputmode}
{maxlength}
{autocomplete}
{disabled}
{...$$restProps}
/>
+6
View File
@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+10
View File
@@ -0,0 +1,10 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./styles.css";
const target = document.getElementById("app");
if (target) {
mount(App, { target });
}
@@ -0,0 +1,17 @@
<script>
import { ArrowLeft } from "lucide-svelte";
export let title = "";
export let subtitle = "";
</script>
<header class="screen-head">
<button class="btn btn-icon btn-square" type="button" aria-label="Назад">
<ArrowLeft size={18} />
</button>
<div class="center-copy">
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
<span></span>
</header>
@@ -0,0 +1,12 @@
<script>
export let number = "";
export let label = "";
export let wide = false;
</script>
<section class:wide class="preview-phone-wrap">
<h2><span>{number}.</span> {label}</h2>
<div class="preview-phone">
<slot />
</div>
</section>
@@ -0,0 +1,26 @@
<script>
import { CreditCard, Send, WalletCards } from "lucide-svelte";
export let methods = [];
const icons = [CreditCard, Send, WalletCards, WalletCards];
function note(index) {
if (index === 0) return "Visa, Mastercard";
if (index === 1) return "Быстро и удобно";
if (index === 2) return "USDT, BTC, ETH";
return "ЮMoney, СБП и др.";
}
</script>
<div class="method-grid">
{#each methods as method, index}
<div class:active={index === 1} class="method-card">
<svelte:component this={icons[index] || WalletCards} size={18} />
<span>
<strong>{method.name}</strong>
<small>{note(index)}</small>
</span>
</div>
{/each}
</div>
@@ -0,0 +1,11 @@
<script>
import { Gift, Home, Settings } from "lucide-svelte";
export let active = "home";
</script>
<nav class="bottom-nav static">
<button class:active={active === "home"} type="button"><Home size={20} /><span>Главная</span></button>
<button class:active={active === "invite"} type="button"><Gift size={20} /><span>Пригласить</span></button>
<button class:active={active === "settings"} type="button"><Settings size={20} /><span>Настройки</span></button>
</nav>
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const templateDir = path.resolve(__dirname, "../templates");
export default defineConfig({
plugins: [svelte()],
build: {
outDir: templateDir,
emptyOutDir: false,
minify: false,
sourcemap: false,
cssCodeSplit: false,
lib: {
entry: path.resolve(__dirname, "src/main.js"),
name: "SubscriptionWebApp",
formats: ["iife"],
fileName: () => "subscription_webapp.js",
cssFileName: "subscription_webapp",
},
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name && assetInfo.name.endsWith(".css")) {
return "subscription_webapp.css";
}
return "subscription_webapp.[name][extname]";
},
},
},
},
});
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+10 -277
View File
@@ -5,292 +5,25 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#05070a">
<meta name="theme-color" content="#03070b">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>Моя подписка</title>
<title>/minishop</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
@font-face {
font-family: 'Twemoji Country Flags';
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
src: url('https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2') format('woff2');
font-display: swap;
}
</style>
<script src="./telegram-web-app.js"></script>
<link rel="stylesheet" href="./subscription_webapp.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="/subscription_webapp.css">
</head>
<body>
<div id="loader" class="app-loader" data-i18n="loading">Загрузка...</div>
<main id="app" class="app hidden">
<header class="app-header">
<div class="app-header-title">
<div class="brand-logo-shell hidden" data-brand-logo-shell aria-hidden="true">
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
<img id="brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
</div>
<div id="brand-title" class="brand-title" data-brand-title>Моя подписка</div>
</div>
<div class="app-header-actions">
<div class="lang-menu">
<button id="lang-chip" class="lang-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="lang-dropdown" aria-label="Language" data-action="toggle-lang-menu">
<span id="lang-chip-flag" class="lang-chip-flag" aria-hidden="true">🇷🇺</span>
<span id="lang-chip-label" class="lang-chip-label">RU</span>
<svg class="lang-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
</svg>
</button>
<div id="lang-dropdown" class="lang-dropdown hidden" role="menu"></div>
</div>
<div class="user-menu">
<button id="user-chip" class="user-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="user-dropdown" data-action="toggle-user-menu">
<img id="user-chip-avatar" class="user-chip-avatar" alt="" aria-hidden="true">
<span id="user-chip-name" class="user-chip-name">...</span>
<svg class="user-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
</svg>
</button>
<div id="user-dropdown" class="user-dropdown hidden" role="menu">
<div class="user-dropdown-head">
<img id="user-dropdown-avatar" class="user-dropdown-avatar" alt="" aria-hidden="true">
<div class="user-dropdown-head-text">
<div id="user-dropdown-name" class="user-dropdown-name">...</div>
<div id="user-dropdown-sub" class="user-dropdown-sub"></div>
</div>
</div>
<div class="user-dropdown-rows">
<div class="user-dropdown-row" role="menuitem">
<span class="user-dropdown-row-label" data-i18n="email_label">Email</span>
<span id="user-dropdown-email-status" class="user-dropdown-row-status">...</span>
</div>
<div class="user-dropdown-row" role="menuitem">
<span class="user-dropdown-row-label" data-i18n="telegram_label">Telegram</span>
<span id="user-dropdown-telegram-status" class="user-dropdown-row-status">...</span>
</div>
</div>
<button class="user-dropdown-logout" type="button" role="menuitem" data-action="logout">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
<path d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"/>
</svg>
<span data-i18n="logout">Выйти</span>
</button>
</div>
</div>
</div>
</header>
<section class="panel grid gap-[17px] p-[18px]">
<div id="sub-status" class="sub-status">
<span class="sub-status-dot" aria-hidden="true"></span>
<span id="sub-status-text" class="sub-status-text">...</span>
</div>
<div class="sub-countdown">
<div class="sub-countdown-row">
<span id="remaining" class="sub-countdown-value">...</span>
<span id="end-date-caption" class="sub-countdown-caption"></span>
</div>
</div>
<div id="traffic-bar" class="traffic-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100">
<div id="traffic-bar-fill" class="traffic-bar-fill"></div>
<span id="traffic" class="traffic-bar-value">...</span>
</div>
<div id="connect-actions">
<button id="connect-btn" class="btn-primary w-full" type="button" data-action="open-connect-link" data-i18n="connect">Подключиться</button>
</div>
<div class="mt-0.5 border-t border-[var(--border)] pt-3.5">
<button id="extend-btn" class="btn-ghost w-full" type="button" data-action="toggle-payment-flow" data-i18n="extend_subscription">Продлить подписку/Добавить дни</button>
</div>
</section>
<section id="link-panel" class="panel hidden grid gap-3.5 p-[17px]">
<div class="panel-head">
<div>
<div id="link-panel-title" class="section-title" data-i18n="link_panel_title">Привяжите способ входа</div>
<div id="link-panel-caption" class="section-caption" data-i18n="link_panel_caption">Чтобы не терять доступ к кабинету</div>
</div>
</div>
<div id="email-link-box" class="hidden grid gap-2">
<div class="link-cta-head">
<span class="link-cta-title" data-i18n="link_email_title">Привяжите email</span>
<span class="link-cta-caption" data-i18n="link_email_caption">Получайте код для входа на почту</span>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
<input id="email-link-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
<button id="email-link-send-btn" class="btn" type="button" data-action="request-email-link-code" data-i18n="send_code">Отправить код</button>
</div>
<div id="email-link-code-row" class="hidden grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
<input id="email-link-code-input" class="input input-code w-full" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
<button id="email-link-verify-btn" class="btn-primary" type="button" data-action="verify-email-link-code" data-i18n="confirm">Подтвердить</button>
</div>
</div>
<div id="telegram-link-box" class="hidden grid gap-2">
<div class="link-cta-head">
<span class="link-cta-title" data-i18n="link_telegram_title">Привяжите Telegram</span>
<span class="link-cta-caption" data-i18n="link_telegram_caption">Входите одним нажатием через Telegram</span>
</div>
<div id="telegram-link-widget" class="telegram-login-widget" aria-live="polite"></div>
<div id="telegram-link-status" class="status-text hidden" aria-live="polite"></div>
</div>
</section>
<section class="grid min-w-0 grid-cols-2 gap-2">
<button class="btn w-full" type="button" data-action="open-referral-modal" data-i18n="referral_title">Пригласить друга</button>
<button class="btn w-full" type="button" data-action="open-promo-modal" data-i18n="promo_title">Промокод</button>
</section>
<a id="support-link" class="hidden mt-0.5 btn" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
<div id="legal-links-app" data-legal-links class="legal-links hidden">
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
</div>
</main>
<div id="payment-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="payment-title">
<button class="modal-backdrop" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow"></button>
<section id="payment-flow" class="panel-modal modal-card grid gap-4 p-4">
<div class="panel-head">
<div>
<div id="payment-title" class="section-title text-[var(--accent)]" data-i18n="payment_title">Оплата подписки</div>
</div>
<button class="icon-btn" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow">×</button>
</div>
<div class="grid gap-2.5">
<div class="section-label" data-i18n="select_period">Выберите период</div>
<div id="plans" class="grid grid-cols-2 gap-2 sm:grid-cols-4"></div>
</div>
<div class="notice grid gap-1.5">
<div class="section-label" data-i18n="payment_amount_label">Стоимость выбранного периода</div>
<div id="selected-plan-price" class="text-[20px] font-extrabold leading-tight text-[var(--text-primary)]">...</div>
</div>
<div class="grid gap-2.5">
<div class="section-label" data-i18n="choose_payment_method">Способ оплаты</div>
<div id="payment-methods" class="grid gap-2"></div>
</div>
</section>
</div>
<div id="promo-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="promo-title">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal"></button>
<section id="promo-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]">
<div class="panel-head">
<div>
<div id="promo-title" class="section-title text-[var(--accent)]" data-i18n="promo_title">Промокод</div>
<div class="section-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
</div>
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal">×</button>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
<input id="promo-code-input" class="input input-code w-full" type="text" autocomplete="off" inputmode="text" placeholder="PROMO2026" data-placeholder-i18n="promo_placeholder">
<button id="promo-apply-btn" class="btn-primary" type="button" data-action="apply-promo-code" data-i18n="apply_promo">Применить</button>
</div>
<div id="promo-status" class="status-text hidden" aria-live="polite"></div>
</section>
</div>
<div id="referral-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="referral-title">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-referral-modal"></button>
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
</div>
<div id="account-merge-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="account-merge-title" aria-describedby="account-merge-caption">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-account-merge-modal"></button>
<section id="account-merge-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
</div>
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal"></button>
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
<section class="panel-modal modal-card grid gap-3.5 p-[18px]">
<div class="panel-head">
<div>
<div id="email-code-title" class="section-title" data-i18n="email_code_title">Подтвердите вход</div>
<div id="email-code-caption" class="section-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
</div>
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal">×</button>
</div>
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.6fr)_minmax(0,1.4fr)]">
<div class="metric-label" data-i18n="email_label">Email</div>
<div id="email-code-address" class="metric-value">...</div>
</div>
<label class="relative block min-h-14 cursor-text">
<input id="email-login-code-input" class="otp-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" data-aria-i18n="email_code_aria" aria-label="Код подтверждения">
<span class="otp-slots" aria-hidden="true">
<span id="email-code-slot-0"></span>
<span id="email-code-slot-1"></span>
<span id="email-code-slot-2"></span>
<span id="email-code-slot-3"></span>
<span id="email-code-slot-4"></span>
<span id="email-code-slot-5"></span>
</span>
</label>
<div id="email-code-status" class="status-text hidden" aria-live="polite"></div>
<div class="grid gap-2">
<button id="email-login-verify-btn" class="btn-primary w-full" type="button" data-action="verify-email-login-code" data-i18n="login">Войти</button>
</div>
</section>
<button id="email-login-resend-btn" class="code-modal-resend" type="button" data-action="resend-email-login-code" data-i18n="resend_code">Отправить еще раз</button>
</div>
</div>
<section id="login" class="login login-layout gap-5">
<div class="grid justify-items-center gap-3 text-center">
<div class="brand-logo-shell brand-logo-shell--lg hidden" data-brand-logo-shell aria-hidden="true">
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
<img id="login-brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
</div>
<div id="login-brand-title" class="max-w-[320px] break-words font-[family-name:var(--font-mono)] text-[28px] font-extrabold leading-[1.05] tracking-[0] text-[var(--accent)] max-[460px]:max-w-[240px] max-[460px]:text-[24px]" data-brand-title>Моя подписка</div>
</div>
<div class="panel-modal w-full grid gap-5 p-6 text-center">
<h1 class="section-title text-[22px] leading-[1.1]" data-i18n="login_title">Войдите или зарегистрируйтесь</h1>
<div class="grid gap-2.5">
<div class="auth-tabs-wrapper" role="tablist">
<button id="email-auth-tab" class="auth-tab active" type="button" data-action="set-auth-mode" data-mode="email" data-i18n="email_login_tab">Email</button>
<button id="telegram-auth-tab" class="auth-tab" type="button" data-action="set-auth-mode" data-mode="telegram" data-i18n="telegram_login_tab">Telegram</button>
</div>
<div class="login-auth-body">
<div id="email-login-pane" class="grid gap-2.5">
<div class="grid gap-2.5">
<input id="email-login-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
<button id="email-login-send-btn" class="btn-primary w-full" type="button" data-action="request-email-login-code" data-i18n="login_continue">Продолжить</button>
</div>
</div>
<div id="telegram-login-pane" class="hidden grid gap-2.5">
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
</div>
<div id="auth-status" class="status-text hidden" aria-live="polite"></div>
</div>
</div>
</div>
<div id="legal-links-login" data-legal-links class="legal-links hidden">
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
</div>
</section>
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
<main id="app"></main>
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,907 +0,0 @@
@import "tailwindcss";
@source "./subscription_webapp.html";
@source "./subscription_webapp.js";
:root {
color-scheme: dark;
--bg-primary: #05070a;
--bg-card: #0f1521;
--bg-card-hover: #161e2e;
--border: #1e2940;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #00fe7a;
--accent-hover: #5aff9f;
--success: #10b981;
--danger: #ef4444;
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 8px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--app-safe-top-extra: 10px;
--app-safe-top: max(var(--tg-content-safe-area-inset-top, 0px), var(--tg-safe-area-inset-top, 0px), env(safe-area-inset-top));
--app-safe-bottom: max(var(--tg-content-safe-area-inset-bottom, 0px), var(--tg-safe-area-inset-bottom, 0px), env(safe-area-inset-bottom));
}
@layer base {
html,
body {
@apply m-0 min-h-full w-full overflow-x-hidden tracking-[0];
}
body {
@apply overflow-x-hidden bg-[var(--bg-primary)] font-[family-name:var(--font-sans)] text-[var(--text-primary)] antialiased;
-webkit-tap-highlight-color: transparent;
}
body.modal-open {
@apply overflow-hidden;
}
button,
a {
font: inherit;
}
button {
@apply cursor-pointer border-0;
}
button:disabled {
@apply cursor-progress opacity-[0.58];
}
}
@layer components {
.btn-sm {
@apply inline-flex min-h-[38px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[11px] py-2 text-center font-extrabold text-[13px] leading-none text-[var(--text-secondary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
}
.metrics {
@apply grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.018)];
}
.metric {
@apply grid min-h-12 grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] items-center gap-2.5 border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2.5;
}
.auth-tab {
@apply min-h-9 rounded-[var(--radius-sm)] bg-transparent font-[850] text-[var(--text-secondary)] transition-colors;
}
.login-layout {
@apply hidden mx-auto min-h-screen w-[min(100vw,560px)] flex-col items-center justify-center gap-4 px-[14px];
padding-top: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra));
padding-bottom: max(var(--app-safe-bottom), 18px);
}
.section-label {
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold text-[var(--text-muted)];
}
.app-loader {
@apply grid min-h-[78vh] place-items-center font-[family-name:var(--font-mono)] font-extrabold text-[var(--text-muted)];
}
.auth-tabs-wrapper {
@apply grid min-h-[46px] grid-cols-2 gap-1 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.38)] p-1;
}
.panel-head {
@apply flex items-start justify-between gap-3;
}
.section-title {
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
}
.section-caption {
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
}
.metric-label {
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
}
.metric-value {
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
}
.referral-link-row {
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
}
.referral-link-value {
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
}
.bonus-row {
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
}
.empty-state {
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.plan-card {
@apply inline-flex min-h-[44px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-center font-extrabold text-[14px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
transition-duration: 200ms;
}
.plan-card-active {
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] text-[var(--accent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
}
.payment-method-card {
@apply inline-flex min-h-[52px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-4 py-[12px] text-center font-extrabold text-[15px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
transition-duration: 200ms;
}
.payment-method-card--platega {
border-color: rgba(0, 254, 122, 0.36);
background: rgba(0, 254, 122, 0.08);
}
.payment-method-card--cryptopay {
border-color: rgba(34, 211, 238, 0.36);
background: rgba(34, 211, 238, 0.08);
}
.plan-name {
@apply block text-[15px] font-extrabold leading-tight;
}
.plan-meta {
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
}
.plan-price {
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
}
.notice {
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.step-num {
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
}
.step-name {
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
}
.main-value {
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
}
.main-caption {
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
}
.status-text {
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
}
.legal-links {
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5 text-center;
}
.app-header {
@apply flex min-h-12 items-center justify-between gap-3;
}
.app-header-title {
@apply flex min-w-0 items-center gap-2.5;
}
.app-header-actions {
@apply flex min-w-0 items-center gap-2;
}
.panel-head-sm {
@apply flex min-w-0 items-center justify-between gap-2.5;
}
.user-menu {
@apply relative inline-flex;
}
.lang-menu {
@apply relative inline-flex;
}
.lang-chip {
@apply inline-flex min-h-[38px] cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-2 text-left font-extrabold text-[12px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
}
.lang-chip[aria-expanded="true"] {
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
}
.lang-chip-flag {
@apply text-[14px] leading-none;
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.lang-chip-label {
@apply font-[family-name:var(--font-mono)] text-[11px] tracking-wide;
}
.lang-chip-caret {
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
}
.lang-chip[aria-expanded="true"] .lang-chip-caret {
transform: rotate(180deg);
}
.lang-dropdown {
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[160px] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
backdrop-filter: blur(14px);
will-change: opacity, transform;
}
.lang-dropdown.show {
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
}
.lang-dropdown-item {
@apply flex cursor-pointer items-center gap-2.5 border-0 bg-transparent px-3 py-2.5 text-left text-[13px] font-bold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
}
.lang-dropdown-item + .lang-dropdown-item {
@apply border-t border-[var(--border)];
}
.lang-dropdown-item.is-active {
@apply bg-[var(--bg-card-hover)] text-[var(--text-primary)];
}
.lang-dropdown-flag {
@apply text-[16px] leading-none;
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.lang-dropdown-label {
@apply flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
}
.user-chip {
@apply inline-flex min-h-[38px] min-w-0 max-w-[200px] cursor-pointer items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] py-1 pl-1 pr-2.5 text-left font-extrabold text-[13px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
}
.user-chip[aria-expanded="true"] {
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
}
.user-chip-avatar {
@apply h-[28px] w-[28px] flex-none rounded-[var(--radius-sm)] object-cover;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
image-rendering: pixelated;
}
.user-chip-name {
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
}
.user-chip-caret {
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
}
.user-chip[aria-expanded="true"] .user-chip-caret {
transform: rotate(180deg);
}
.user-dropdown {
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[min(calc(100vw-28px),280px)] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
backdrop-filter: blur(14px);
will-change: opacity, transform;
}
.user-dropdown.show {
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
}
.user-dropdown-head {
@apply flex items-center gap-2.5 border-b border-[var(--border)] px-3 py-2.5;
}
.user-dropdown-avatar {
@apply h-[40px] w-[40px] flex-none rounded-[var(--radius-md)] object-cover;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
image-rendering: pixelated;
}
.user-dropdown-head-text {
@apply grid min-w-0 gap-0.5;
}
.user-dropdown-name {
@apply overflow-hidden text-ellipsis whitespace-nowrap text-sm font-extrabold text-[var(--text-primary)];
}
.user-dropdown-sub {
@apply overflow-hidden text-ellipsis whitespace-nowrap font-[family-name:var(--font-mono)] text-[11px] text-[var(--text-muted)];
}
.user-dropdown-rows {
@apply grid;
}
.user-dropdown-row {
@apply flex items-center justify-between gap-3 px-3 py-2.5;
}
.user-dropdown-row + .user-dropdown-row {
@apply border-t border-[var(--border)];
}
.user-dropdown-row-label {
@apply font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
}
.user-dropdown-row-status {
@apply overflow-hidden text-ellipsis whitespace-nowrap text-right text-[13px] font-bold text-[var(--text-primary)];
}
.user-dropdown-row-status.linked {
@apply text-[#6ee7b7];
}
.user-dropdown-row-status.unlinked {
@apply text-[var(--text-muted)];
}
.user-dropdown-logout {
@apply flex min-h-[42px] w-full cursor-pointer items-center justify-center gap-2 rounded-none border-0 border-t border-[var(--border)] bg-transparent px-3 py-2.5 text-center text-[13px] font-extrabold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
}
.user-dropdown-logout svg {
@apply h-4 w-4 fill-current;
}
.sub-status {
@apply flex min-h-[28px] items-center gap-2.5 text-[var(--danger)];
}
.sub-status.active {
@apply text-[var(--success)];
}
.sub-status-dot {
@apply relative inline-block h-[10px] w-[10px] flex-none rounded-full;
background: currentColor;
box-shadow: 0 0 12px currentColor;
}
.sub-status-dot::after {
content: '';
position: absolute;
inset: 0;
border-radius: 9999px;
background: currentColor;
opacity: 0.55;
animation: sub-status-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.sub-status-text {
@apply font-extrabold text-[15px] leading-tight;
letter-spacing: 0.01em;
}
@keyframes brand-logo-spin {
to {
transform: rotate(360deg);
}
}
.sub-countdown {
@apply grid gap-1;
}
.sub-countdown-row {
@apply flex flex-wrap items-baseline gap-x-2 gap-y-0.5;
}
.sub-countdown-value {
@apply font-extrabold text-[22px] leading-[1.1] text-[var(--text-primary)];
}
.sub-countdown-caption {
@apply font-[family-name:var(--font-mono)] text-[12px] leading-[1.3] text-[var(--text-muted)];
}
.traffic-bar {
@apply relative flex min-h-[24px] w-full items-center justify-center overflow-hidden rounded-full border border-[var(--border)];
background: rgba(255, 255, 255, 0.02);
}
.traffic-bar-fill {
@apply absolute inset-y-0 left-0;
width: 0%;
background: color-mix(in srgb, var(--accent) 28%, transparent);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease;
}
.traffic-bar.over .traffic-bar-fill {
background: color-mix(in srgb, var(--danger) 32%, transparent);
}
.traffic-bar.unlimited .traffic-bar-fill {
width: 100%;
background: color-mix(in srgb, var(--success) 12%, transparent);
}
.traffic-bar-value {
@apply relative z-[1] min-w-0 px-3 font-[family-name:var(--font-mono)] text-[12px] font-bold text-center whitespace-nowrap;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.traffic-bar.unlimited .traffic-bar-value {
animation: traffic-infinity-pulse 1.8s ease-in-out infinite;
}
@keyframes traffic-infinity-pulse {
0%, 100% {
opacity: 1;
text-shadow: 0 0 0 color-mix(in srgb, var(--success) 0%, transparent);
}
50% {
opacity: 0.7;
text-shadow: 0 0 10px color-mix(in srgb, var(--success) 70%, transparent);
}
}
.link-cta-head {
@apply grid gap-1;
}
.link-cta-title {
@apply font-extrabold text-[15px] leading-tight text-[var(--text-primary)];
}
.link-cta-caption {
@apply text-[13px] leading-[1.4] text-[var(--text-secondary)];
}
@keyframes sub-status-pulse {
0% {
transform: scale(1);
opacity: 0.55;
}
70% {
transform: scale(2.2);
opacity: 0;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
.login-head {
@apply flex min-w-0 items-center justify-center gap-3.5;
}
.btn {
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[13px] py-[11px] text-center font-extrabold text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
overflow-wrap: anywhere;
}
.btn-primary {
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_80%,var(--border))] bg-[var(--accent)] px-[13px] py-[11px] text-center font-extrabold text-[#04110a] no-underline shadow-[0_10px_26px_color-mix(in_srgb,var(--accent)_18%,transparent)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[var(--accent-hover)] hover:shadow-[0_14px_34px_color-mix(in_srgb,var(--accent)_24%,transparent)];
overflow-wrap: anywhere;
}
.btn-ghost {
@apply inline-flex min-h-[46px] min-w-0 w-full cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_35%,var(--border))] bg-[color-mix(in_srgb,var(--accent)_5%,transparent)] px-[13px] py-[11px] text-center font-extrabold text-[var(--accent)] no-underline shadow-none transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover:shadow-[0_10px_24px_color-mix(in_srgb,var(--accent)_10%,transparent)];
overflow-wrap: anywhere;
}
.icon-btn {
@apply inline-flex h-[38px] w-[38px] flex-none cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] text-[var(--text-secondary)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
}
.input {
@apply min-h-[46px] w-full rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.42)] px-3 py-[11px] font-bold text-[var(--text-primary)] outline-none transition-[border-color,background,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] placeholder:text-[var(--text-muted)] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:bg-[rgba(7,12,19,0.68)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_12%,transparent),0_12px_28px_rgba(0,0,0,0.2)] aria-[invalid=true]:border-[color-mix(in_srgb,var(--danger)_72%,var(--border))] aria-[invalid=true]:focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--danger)_14%,transparent)];
}
.input-code {
@apply text-center font-[family-name:var(--font-mono)] tracking-[0];
}
.panel {
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_20px_40px_rgba(0,0,0,0.18)];
}
.panel-modal {
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_30px_60px_rgba(0,0,0,0.28)];
}
.step-box {
@apply min-h-[58px] rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[9px] text-[var(--text-muted)] shadow-[inset_0_1px_0_rgba(255,255,255,0.045)];
}
.modal {
@apply fixed inset-0 z-20 grid place-items-center opacity-0 transition-opacity pointer-events-none;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
}
.modal.show {
@apply opacity-100 pointer-events-auto;
}
.modal-backdrop,
.tools-modal-backdrop {
@apply absolute inset-0 border-0 p-0;
background: rgba(3, 6, 10, 0.72);
backdrop-filter: blur(7px);
}
.tools-modal-backdrop {
@apply fixed z-25;
}
.modal-card {
@apply relative max-h-[min(86vh,760px)] w-[min(100%,560px)] overflow-auto transition-transform duration-[0.22s] ease-[cubic-bezier(0.2,0.8,0.2,1)];
transform: translateY(18px) scale(0.98);
}
.modal.show .modal-card {
transform: translateY(0) scale(1);
}
.app {
@apply mx-auto flex w-[min(100vw,560px)] max-w-[100vw] flex-col gap-4;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 22px);
}
@media (min-width: 561px) {
.app {
@apply min-h-screen justify-center;
}
}
.telegram-login-widget {
@apply flex min-w-0 items-center;
}
.brand-logo-shell {
@apply relative grid h-[38px] w-[38px] flex-none place-items-center overflow-hidden;
}
.brand-logo-shell--lg {
@apply h-[96px] w-[96px];
}
.brand-logo {
@apply absolute inset-0 h-full w-full object-contain;
}
.brand-logo-spinner {
@apply h-5 w-5 flex-none rounded-full border-2;
border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
border-top-color: var(--accent);
animation: brand-logo-spin 0.85s linear infinite;
}
.brand-logo--lg {
@apply h-[84px] w-[84px];
}
.plans,
.methods,
.metrics,
.referral-link-list,
.bonus-list {
@apply grid min-w-0;
}
.actions,
.otp-slots,
.metric {
@apply grid min-w-0 gap-2;
}
.actions {
grid-template-columns: minmax(0, 1fr) 48px;
}
.metric {
grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr);
}
.metrics {
@apply gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)];
background: rgba(255, 255, 255, 0.018);
}
.metric,
.notice,
.otp-slots span {
@apply border border-[var(--border)];
background: rgba(255, 255, 255, 0.02);
}
.metric {
@apply min-h-[46px] items-center gap-2.5 px-3 py-2.5;
}
.metric {
@apply min-h-12;
}
.metric + .metric {
@apply border-t border-[var(--border)];
}
.brand-title,
.login-brand-title {
@apply max-w-[260px] font-[family-name:var(--font-mono)] font-extrabold tracking-[0] text-[var(--accent)];
}
.brand-title {
@apply overflow-hidden text-ellipsis whitespace-nowrap text-[17px] leading-[1.15];
text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 18%, transparent);
}
.login-brand-title {
@apply text-left text-2xl leading-[1.1];
overflow-wrap: anywhere;
}
.login-auth-body {
@apply grid w-full min-h-[140px] gap-2.5 content-start;
}
.login-auth-body--telegram {
@apply content-center;
}
.login-text,
.promo-status {
@apply text-[var(--text-secondary)];
}
.section-label {
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
overflow-wrap: anywhere;
}
.section-label {
@apply font-extrabold;
}
.mono-pill,
.badge {
@apply inline-flex min-h-7 items-center rounded-[var(--radius-md)] px-2.5 font-[family-name:var(--font-mono)] text-[11px] font-bold whitespace-nowrap;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.mono-pill {
@apply text-[var(--accent)];
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.badge {
@apply min-w-0 overflow-hidden text-ellipsis text-[#6ee7b7];
border: 1px solid color-mix(in srgb, var(--success) 34%, var(--border));
background: rgba(16, 185, 129, 0.12);
}
.badge.off,
.promo-status.error,
.login-status.error {
@apply text-[#fca5a5];
}
.badge.off {
border-color: color-mix(in srgb, var(--danger) 34%, var(--border));
background: rgba(239, 68, 68, 0.12);
}
.promo-status.success {
@apply text-[#6ee7b7];
}
#legal-links-login {
@apply w-[min(100vw,560px)];
}
.legal-link,
.code-modal-resend {
@apply border-0 bg-transparent p-0 text-[13px] font-bold text-[var(--text-secondary)] underline underline-offset-[3px] transition-colors;
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
}
.legal-link {
@apply inline flex-initial;
}
.otp-input {
@apply absolute inset-0 z-2 h-full w-full border-0 bg-transparent text-transparent opacity-[0.01] outline-none;
caret-color: transparent;
}
.otp-slots {
@apply grid-cols-6;
}
.otp-slots span {
@apply grid aspect-square min-w-0 place-items-center rounded-[var(--radius-md)] font-[family-name:var(--font-mono)] text-xl font-[850] leading-none text-[var(--text-primary)] transition-[border-color,background,box-shadow];
background: rgba(5, 8, 13, 0.42);
}
.otp-slots span.filled {
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
}
.otp-slots span.active {
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.step-num,
.step-name {
@apply block text-current;
}
.step-num {
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1];
}
.step-name {
@apply mt-1 text-xs font-extrabold leading-[1.15];
}
.step.active,
.auth-tab.active {
@apply border-[var(--accent)] text-[var(--accent)];
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.step.done {
@apply text-[var(--text-secondary)];
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.notice {
@apply rounded-[var(--radius-md)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.login-layout.show {
@apply flex;
}
.telegram-login-widget {
@apply min-h-14 justify-center;
}
.login-text,
.promo-status {
@apply m-0 text-sm leading-[1.45];
}
.promo-status {
@apply mt-0.5 min-h-[22px];
overflow-wrap: anywhere;
}
.login-status {
@apply m-0 min-h-5;
}
.toast {
@apply fixed left-1/2 z-40 w-max max-w-[calc(100vw-28px)] rounded-[var(--radius-md)] px-[13px] py-[11px] text-[13px] font-bold text-[var(--text-primary)] opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)];
bottom: max(var(--app-safe-bottom), 22px);
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background: rgba(15, 21, 33, 0.82);
backdrop-filter: blur(14px);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.45), 0 0 15px color-mix(in srgb, var(--accent) 15%, transparent);
pointer-events: none;
transform: translate(-50%, 14px) scale(0.94);
}
.toast.show {
@apply opacity-100;
transform: translate(-50%, 0) scale(1);
}
}
.hidden {
display: none !important;
}
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
.mono-pill,
.step.active,
.auth-tab.active {
border-color: var(--accent);
}
.mono-pill,
.step.active,
.auth-tab.active {
background: rgba(0, 254, 122, 0.08);
}
}
@media (hover: hover) and (pointer: fine) {
.legal-link:hover:not(:disabled),
.code-modal-resend:hover:not(:disabled) {
@apply text-[var(--accent)];
}
}
@media (max-width: 460px) {
.app {
@apply px-2.5;
}
.user-chip {
@apply max-w-[150px];
}
.user-chip-name {
@apply hidden;
}
.brand-title {
@apply max-w-[164px];
}
.metric {
@apply grid-cols-1;
}
.badge,
.mono-pill {
@apply max-w-full;
}
.otp-slots {
@apply gap-1.5;
}
.otp-slots span {
@apply text-lg;
}
.brand-logo--lg {
@apply h-[68px] w-[68px];
}
.brand-logo-shell--lg {
@apply h-[96px] w-[96px];
}
.login-auth-body {
@apply min-h-[128px];
}
.login-brand-title {
@apply max-w-[200px] text-[21px];
}
}
@media (max-width: 380px) {
.brand-title {
@apply max-w-[150px];
}
}
@media (max-width: 560px) {
.step-box {
@apply min-h-[54px] px-[7px] py-2;
}
.modal {
@apply place-items-center p-0;
top: 0;
bottom: 0;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
}
.modal--page {
@apply place-items-stretch p-0;
padding: 0 !important;
}
.modal--page .modal-backdrop {
@apply bg-[var(--bg-primary)] opacity-100;
backdrop-filter: none;
}
.modal-card {
@apply h-auto max-h-[min(92vh,760px)] w-full rounded-[var(--radius-lg)];
transform: translateY(12px) scale(0.98);
}
.modal--page .modal-card {
@apply h-full w-full max-h-none max-w-none rounded-none border-0 shadow-none;
transform: none !important;
}
.modal.show .modal-card {
transform: translateY(0) scale(1);
}
}
File diff suppressed because it is too large Load Diff
-575
View File
@@ -1,575 +0,0 @@
(function(window) {
(function(window){
window.__parseFunction = function(__func, __attrs) {
__attrs = __attrs || [];
__func = '(function(' + __attrs.join(',') + '){' + __func + '})';
return window.execScript ? window.execScript(__func) : eval(__func);
}
}(window));
(function(window){
function addEvent(el, event, handler) {
var events = event.split(/\s+/);
for (var i = 0; i < events.length; i++) {
if (el.addEventListener) {
el.addEventListener(events[i], handler);
} else {
el.attachEvent('on' + events[i], handler);
}
}
}
function removeEvent(el, event, handler) {
var events = event.split(/\s+/);
for (var i = 0; i < events.length; i++) {
if (el.removeEventListener) {
el.removeEventListener(events[i], handler);
} else {
el.detachEvent('on' + events[i], handler);
}
}
}
function getCssProperty(el, prop) {
if (window.getComputedStyle) {
return window.getComputedStyle(el, '').getPropertyValue(prop) || null;
} else if (el.currentStyle) {
return el.currentStyle[prop] || null;
}
return null;
}
function geById(el_or_id) {
if (typeof el_or_id == 'string' || el_or_id instanceof String) {
return document.getElementById(el_or_id);
} else if (el_or_id instanceof HTMLElement) {
return el_or_id;
}
return null;
}
var getWidgetsOrigin = function(default_origin, dev_origin) {
var link = document.createElement('A'), origin;
link.href = document.currentScript && document.currentScript.src || default_origin;
origin = link.origin || link.protocol + '//' + link.hostname;
if (origin == 'https://telegram.org') {
origin = default_origin;
} else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {
origin = dev_origin;
} else {
origin = default_origin;
}
return origin;
};
var getPageCanonical = function() {
var a = document.createElement('A'), link, href;
if (document.querySelector) {
link = document.querySelector('link[rel="canonical"]');
if (link && (href = link.getAttribute('href'))) {
a.href = href;
return a.href;
}
} else {
var links = document.getElementsByTagName('LINK');
for (var i = 0; i < links.length; i++) {
if ((link = links[i]) &&
(link.getAttribute('rel') == 'canonical') &&
(href = link.getAttribute('href'))) {
a.href = href;
return a.href;
}
}
}
return false;
};
function haveTgAuthResult() {
var locationHash = '', re = /[#\?\&]tgAuthResult=([A-Za-z0-9\-_=]*)$/, match;
try {
locationHash = location.hash.toString();
if (match = locationHash.match(re)) {
location.hash = locationHash.replace(re, '');
var data = match[1] || '';
data = data.replace(/-/g, '+').replace(/_/g, '/');
var pad = data.length % 4;
if (pad > 1) {
data += new Array(5 - pad).join('=');
}
return JSON.parse(window.atob(data));
}
} catch (e) {}
return false;
}
function getXHR() {
if (navigator.appName == "Microsoft Internet Explorer"){
return new ActiveXObject("Microsoft.XMLHTTP");
} else {
return new XMLHttpRequest();
}
}
if (!window.Telegram) {
window.Telegram = {};
}
if (!window.Telegram.__WidgetUuid) {
window.Telegram.__WidgetUuid = 0;
}
if (!window.Telegram.__WidgetLastId) {
window.Telegram.__WidgetLastId = 0;
}
if (!window.Telegram.__WidgetCallbacks) {
window.Telegram.__WidgetCallbacks = {};
}
function postMessageToIframe(iframe, event, data, callback) {
if (!iframe._ready) {
if (!iframe._readyQueue) iframe._readyQueue = [];
iframe._readyQueue.push([event, data, callback]);
return;
}
try {
data = data || {};
data.event = event;
if (callback) {
data._cb = ++window.Telegram.__WidgetLastId;
window.Telegram.__WidgetCallbacks[data._cb] = {
iframe: iframe,
callback: callback
};
}
iframe.contentWindow.postMessage(JSON.stringify(data), '*');
} catch(e) {}
}
function initWidget(widgetEl) {
var widgetId, widgetElId, widgetsOrigin, existsEl,
src, styles = {}, allowedAttrs = [],
defWidth, defHeight, scrollable = false, onInitAuthUser, onAuthUser, onUnauth;
if (!widgetEl.tagName ||
!(widgetEl.tagName.toUpperCase() == 'SCRIPT' ||
widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE' &&
widgetEl.classList.contains('telegram-post'))) {
return null;
}
if (widgetEl._iframe) {
return widgetEl._iframe;
}
if (widgetId = widgetEl.getAttribute('data-telegram-post')) {
var comment = widgetEl.getAttribute('data-comment') || '';
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-post-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + (comment ? '-comment' + comment : '');
src = widgetsOrigin + '/' + widgetId + '?embed=1';
allowedAttrs = ['comment', 'userpic', 'mode', 'single?', 'color', 'dark', 'dark_color'];
defWidth = widgetEl.getAttribute('data-width') || '100%';
defHeight = '';
styles.minWidth = '320px';
}
else if (widgetId = widgetEl.getAttribute('data-telegram-discussion')) {
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-discussion-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + '-' + (++window.Telegram.__WidgetUuid);
var websitePageUrl = widgetEl.getAttribute('data-page-url');
if (!websitePageUrl) {
websitePageUrl = getPageCanonical();
}
src = widgetsOrigin + '/' + widgetId + '?embed=1&discussion=1' + (websitePageUrl ? '&page_url=' + encodeURIComponent(websitePageUrl) : '');
allowedAttrs = ['comments_limit', 'color', 'colorful', 'dark', 'dark_color', 'width', 'height'];
defWidth = widgetEl.getAttribute('data-width') || '100%';
defHeight = widgetEl.getAttribute('data-height') || 0;
styles.minWidth = '320px';
if (defHeight > 0) {
scrollable = true;
}
}
else if (widgetEl.hasAttribute('data-telegram-login')) {
widgetId = widgetEl.getAttribute('data-telegram-login');
widgetsOrigin = getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev');
widgetElId = 'telegram-login-' + widgetId.replace(/[^a-z0-9_]/ig, '-');
src = widgetsOrigin + '/embed/' + widgetId + '?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + '&return_to=' + encodeURIComponent(location.href);
allowedAttrs = ['size', 'userpic', 'init_auth', 'request_access', 'radius', 'min_width', 'max_width', 'lang'];
defWidth = 186;
defHeight = 28;
if (widgetEl.hasAttribute('data-size')) {
var size = widgetEl.getAttribute('data-size');
if (size == 'small') defWidth = 148, defHeight = 20;
else if (size == 'large') defWidth = 238, defHeight = 40;
}
if (widgetEl.hasAttribute('data-onauth')) {
onInitAuthUser = onAuthUser = __parseFunction(widgetEl.getAttribute('data-onauth'), ['user']);
}
else if (widgetEl.hasAttribute('data-auth-url')) {
var a = document.createElement('A');
a.href = widgetEl.getAttribute('data-auth-url');
onAuthUser = function(user) {
var authUrl = a.href;
authUrl += (authUrl.indexOf('?') >= 0) ? '&' : '?';
var params = [];
for (var key in user) {
params.push(key + '=' + encodeURIComponent(user[key]));
}
authUrl += params.join('&');
location.href = authUrl;
};
}
if (widgetEl.hasAttribute('data-onunauth')) {
onUnauth = __parseFunction(widgetEl.getAttribute('data-onunauth'));
}
var auth_result = haveTgAuthResult();
if (auth_result && onAuthUser) {
onAuthUser(auth_result);
}
}
else if (widgetId = widgetEl.getAttribute('data-telegram-share-url')) {
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-share-' + window.btoa(widgetId);
src = widgetsOrigin + '/share/embed?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname);
allowedAttrs = ['telegram-share-url', 'comment', 'size', 'text'];
defWidth = 60;
defHeight = 20;
if (widgetEl.getAttribute('data-size') == 'large') {
defWidth = 76;
defHeight = 28;
}
}
else {
return null;
}
existsEl = document.getElementById(widgetElId);
if (existsEl) {
return existsEl;
}
for (var i = 0; i < allowedAttrs.length; i++) {
var attr = allowedAttrs[i];
var novalue = attr.substr(-1) == '?';
if (novalue) {
attr = attr.slice(0, -1);
}
var data_attr = 'data-' + attr.replace(/_/g, '-');
if (widgetEl.hasAttribute(data_attr)) {
var attr_value = novalue ? '1' : encodeURIComponent(widgetEl.getAttribute(data_attr));
src += '&' + attr + '=' + attr_value;
}
}
function getCurCoords(iframe) {
var docEl = document.documentElement;
var frect = iframe.getBoundingClientRect();
return {
frameTop: frect.top,
frameBottom: frect.bottom,
frameLeft: frect.left,
frameRight: frect.right,
frameWidth: frect.width,
frameHeight: frect.height,
scrollTop: window.pageYOffset,
scrollLeft: window.pageXOffset,
clientWidth: docEl.clientWidth,
clientHeight: docEl.clientHeight
};
}
function visibilityHandler() {
if (isVisible(iframe, 50)) {
postMessageToIframe(iframe, 'visible', {frame: widgetElId});
}
}
function focusHandler() {
postMessageToIframe(iframe, 'focus', {has_focus: document.hasFocus()});
}
function postMessageHandler(event) {
if (event.source !== iframe.contentWindow ||
event.origin != widgetsOrigin) {
return;
}
try {
var data = JSON.parse(event.data);
} catch(e) {
var data = {};
}
if (data.event == 'resize') {
if (data.height) {
iframe.style.height = data.height + 'px';
}
if (data.width) {
iframe.style.width = data.width + 'px';
}
}
else if (data.event == 'ready') {
iframe._ready = true;
focusHandler();
for (var i = 0; i < iframe._readyQueue.length; i++) {
var queue_item = iframe._readyQueue[i];
postMessageToIframe(iframe, queue_item[0], queue_item[1], queue_item[2]);
}
iframe._readyQueue = [];
}
else if (data.event == 'visible_off') {
removeEvent(window, 'scroll', visibilityHandler);
removeEvent(window, 'resize', visibilityHandler);
}
else if (data.event == 'get_coords') {
postMessageToIframe(iframe, 'callback', {
_cb: data._cb,
value: getCurCoords(iframe)
});
}
else if (data.event == 'scroll_to') {
try {
window.scrollTo(data.x || 0, data.y || 0);
} catch(e) {}
}
else if (data.event == 'auth_user') {
if (data.init) {
onInitAuthUser && onInitAuthUser(data.auth_data);
} else {
onAuthUser && onAuthUser(data.auth_data);
}
}
else if (data.event == 'unauthorized') {
onUnauth && onUnauth();
}
else if (data.event == 'callback') {
var cb_data = null;
if (cb_data = window.Telegram.__WidgetCallbacks[data._cb]) {
if (cb_data.iframe === iframe) {
cb_data.callback(data.value);
delete window.Telegram.__WidgetCallbacks[data._cb];
}
} else {
console.warn('Callback #' + data._cb + ' not found');
}
}
}
var iframe = document.createElement('iframe');
iframe.id = widgetElId;
iframe.src = src;
iframe.width = defWidth;
iframe.height = defHeight;
iframe.setAttribute('frameborder', '0');
if (!scrollable) {
iframe.setAttribute('scrolling', 'no');
iframe.style.overflow = 'hidden';
}
iframe.style.colorScheme = 'light dark';
iframe.style.border = 'none';
for (var prop in styles) {
iframe.style[prop] = styles[prop];
}
if (widgetEl.parentNode) {
widgetEl.parentNode.insertBefore(iframe, widgetEl);
if (widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE') {
widgetEl.parentNode.removeChild(widgetEl);
}
}
iframe._ready = false;
iframe._readyQueue = [];
widgetEl._iframe = iframe;
addEvent(iframe, 'load', function() {
removeEvent(iframe, 'load', visibilityHandler);
addEvent(window, 'scroll', visibilityHandler);
addEvent(window, 'resize', visibilityHandler);
visibilityHandler();
});
addEvent(window, 'focus blur', focusHandler);
addEvent(window, 'message', postMessageHandler);
return iframe;
}
function isVisible(el, padding) {
var node = el, val;
var visibility = getCssProperty(node, 'visibility');
if (visibility == 'hidden') return false;
while (node) {
if (node === document.documentElement) break;
var display = getCssProperty(node, 'display');
if (display == 'none') return false;
var opacity = getCssProperty(node, 'opacity');
if (opacity !== null && opacity < 0.1) return false;
node = node.parentNode;
}
if (el.getBoundingClientRect) {
padding = +padding || 0;
var rect = el.getBoundingClientRect();
var html = document.documentElement;
if (rect.bottom < padding ||
rect.right < padding ||
rect.top > (window.innerHeight || html.clientHeight) - padding ||
rect.left > (window.innerWidth || html.clientWidth) - padding) {
return false;
}
}
return true;
}
function getAllWidgets() {
var widgets = [];
if (document.querySelectorAll) {
widgets = document.querySelectorAll('script[data-telegram-post],blockquote.telegram-post,script[data-telegram-discussion],script[data-telegram-login],script[data-telegram-share-url]');
} else {
widgets = Array.prototype.slice.apply(document.getElementsByTagName('SCRIPT'));
widgets = widgets.concat(Array.prototype.slice.apply(document.getElementsByTagName('BLOCKQUOTE')));
}
return widgets;
}
function getWidgetInfo(el_or_id, callback) {
var e = null, iframe = null;
if (el = geById(el_or_id)) {
if (el.tagName &&
el.tagName.toUpperCase() == 'IFRAME') {
iframe = el;
} else if (el._iframe) {
iframe = el._iframe;
}
if (iframe && callback) {
postMessageToIframe(iframe, 'get_info', {}, callback);
}
}
}
function setWidgetOptions(options, el_or_id) {
var e = null, iframe = null;
if (typeof el_or_id === 'undefined') {
var widgets = getAllWidgets();
for (var i = 0; i < widgets.length; i++) {
if (iframe = widgets[i]._iframe) {
postMessageToIframe(iframe, 'set_options', {options: options});
}
}
} else {
if (el = geById(el_or_id)) {
if (el.tagName &&
el.tagName.toUpperCase() == 'IFRAME') {
iframe = el;
} else if (el._iframe) {
iframe = el._iframe;
}
if (iframe) {
postMessageToIframe(iframe, 'set_options', {options: options});
}
}
}
}
if (!document.currentScript ||
!initWidget(document.currentScript)) {
var widgets = getAllWidgets();
for (var i = 0; i < widgets.length; i++) {
initWidget(widgets[i]);
}
}
var TelegramLogin = {
popups: {},
options: null,
auth_callback: null,
_init: function(options, auth_callback) {
TelegramLogin.options = options;
TelegramLogin.auth_callback = auth_callback;
var auth_result = haveTgAuthResult();
if (auth_result && auth_callback) {
auth_callback(auth_result);
}
},
_open: function(callback) {
TelegramLogin._auth(TelegramLogin.options, function(authData) {
if (TelegramLogin.auth_callback) {
TelegramLogin.auth_callback(authData);
}
if (callback) {
callback(authData);
}
});
},
_auth: function(options, callback) {
var bot_id = parseInt(options.bot_id);
if (!bot_id) {
throw new Error('Bot id required');
}
var width = 550;
var height = 470;
var left = Math.max(0, (screen.width - width) / 2) + (screen.availLeft | 0),
top = Math.max(0, (screen.height - height) / 2) + (screen.availTop | 0);
var onMessage = function (event) {
try {
var data = JSON.parse(event.data);
} catch(e) {
var data = {};
}
if (!TelegramLogin.popups[bot_id]) return;
if (event.source !== TelegramLogin.popups[bot_id].window) return;
if (data.event == 'auth_result') {
onAuthDone(data.result);
}
};
var onAuthDone = function (authData) {
if (!TelegramLogin.popups[bot_id]) return;
if (TelegramLogin.popups[bot_id].authFinished) return;
callback && callback(authData);
TelegramLogin.popups[bot_id].authFinished = true;
removeEvent(window, 'message', onMessage);
};
var checkClose = function(bot_id) {
if (!TelegramLogin.popups[bot_id]) return;
if (!TelegramLogin.popups[bot_id].window ||
TelegramLogin.popups[bot_id].window.closed) {
return TelegramLogin.getAuthData(options, function(origin, authData) {
onAuthDone(authData);
});
}
setTimeout(checkClose, 100, bot_id);
}
var popup_url = Telegram.Login.widgetsOrigin + '/auth?bot_id=' + encodeURIComponent(options.bot_id) + '&origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + (options.request_access ? '&request_access=' + encodeURIComponent(options.request_access) : '') + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '') + '&return_to=' + encodeURIComponent(location.href);
var popup = window.open(popup_url, 'telegram_oauth_bot' + bot_id, 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',status=0,location=0,menubar=0,toolbar=0');
TelegramLogin.popups[bot_id] = {
window: popup,
authFinished: false
};
if (popup) {
addEvent(window, 'message', onMessage);
popup.focus();
checkClose(bot_id);
}
},
getAuthData: function(options, callback) {
var bot_id = parseInt(options.bot_id);
if (!bot_id) {
throw new Error('Bot id required');
}
var xhr = getXHR();
var url = Telegram.Login.widgetsOrigin + '/auth/get';
xhr.open('POST', url);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (typeof xhr.responseBody == 'undefined' && xhr.responseText) {
try {
var result = JSON.parse(xhr.responseText);
} catch(e) {
var result = {};
}
if (result.user) {
callback(result.origin, result.user);
} else {
callback(result.origin, false);
}
} else {
callback('*', false);
}
}
};
xhr.onerror = function() {
callback('*', false);
};
xhr.withCredentials = true;
xhr.send('bot_id=' + encodeURIComponent(options.bot_id) + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : ''));
}
};
window.Telegram.getWidgetInfo = getWidgetInfo;
window.Telegram.setWidgetOptions = setWidgetOptions;
window.Telegram.Login = {
init: TelegramLogin._init,
open: TelegramLogin._open,
auth: TelegramLogin._auth,
widgetsOrigin: getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev')
};
}(window));
})(window);
+1 -25
View File
@@ -1,7 +1,6 @@
import hmac
import asyncio
import logging
from contextlib import suppress
from aiohttp import web
from aiogram import Bot, Dispatcher
@@ -17,8 +16,6 @@ class SecureSimpleRequestHandler(SimpleRequestHandler):
return False
return hmac.compare_digest(telegram_secret_token, self.secret_token)
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
def _inject_shared_instances(
app: web.Application,
@@ -132,13 +129,8 @@ async def build_and_start_web_app(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
)
telegram_web_app_sdk_refresh_task = None
if settings.WEBAPP_ENABLED:
from bot.app.web.subscription_webapp import (
create_subscription_webapp_application,
refresh_telegram_login_widget_sdk,
refresh_telegram_web_app_sdk,
)
from bot.app.web.subscription_webapp import create_subscription_webapp_application
subscription_app = create_subscription_webapp_application(
dp,
@@ -161,25 +153,9 @@ async def build_and_start_web_app(
settings.WEBAPP_SERVER_PORT,
)
async def _refresh_telegram_web_assets_forever() -> None:
while True:
await refresh_telegram_web_app_sdk()
await refresh_telegram_login_widget_sdk()
await asyncio.sleep(TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS)
telegram_web_app_sdk_refresh_task = asyncio.create_task(
_refresh_telegram_web_assets_forever(),
name="TelegramWebAssetsRefreshTask",
)
try:
await asyncio.Event().wait()
finally:
if telegram_web_app_sdk_refresh_task is not None:
telegram_web_app_sdk_refresh_task.cancel()
with suppress(asyncio.CancelledError):
await telegram_web_app_sdk_refresh_task
for runner in reversed(runners):
try:
await runner.cleanup()
+180
View File
@@ -1,8 +1,10 @@
import base64
import asyncio
import hashlib
import hmac
import json
import logging
import secrets
import time
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl
@@ -13,6 +15,9 @@ logger = logging.getLogger(__name__)
# 5 minutes clock skew tolerance for Telegram clients
TELEGRAM_CLOCK_SKEW_SECONDS = 300
TELEGRAM_OAUTH_ISSUER = "https://oauth.telegram.org"
TELEGRAM_OAUTH_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json"
TELEGRAM_OAUTH_ALGORITHMS = ["RS256", "ES256", "EdDSA"]
def _urlsafe_b64encode(raw: bytes) -> str:
@@ -74,6 +79,181 @@ def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int]
return None
def create_telegram_oauth_nonce(settings: Settings, *, ttl_seconds: int = 600) -> str:
now = int(time.time())
payload = {
"n": secrets.token_urlsafe(24),
"iat": now,
"exp": now + max(60, int(ttl_seconds)),
}
payload_part = _urlsafe_b64encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
)
signature = hmac.new(
_session_secret(settings),
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
hashlib.sha256,
).digest()
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
def verify_telegram_oauth_nonce(settings: Settings, nonce: str) -> bool:
if not nonce or "." not in nonce:
return False
try:
payload_part, signature_part = nonce.split(".", 1)
expected_signature = hmac.new(
_session_secret(settings),
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
hashlib.sha256,
).digest()
received_signature = _urlsafe_b64decode(signature_part)
if not hmac.compare_digest(expected_signature, received_signature):
return False
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
now = int(time.time())
if int(payload.get("exp", 0)) < now:
return False
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
return False
return bool(payload.get("n"))
except Exception as exc:
logger.debug("Failed to verify Telegram OAuth nonce: %s", exc)
return False
def create_signed_telegram_oauth_state(
settings: Settings,
payload: Dict[str, Any],
*,
ttl_seconds: int = 600,
) -> str:
now = int(time.time())
state_payload = {
**payload,
"iat": now,
"exp": now + max(60, int(ttl_seconds)),
}
payload_part = _urlsafe_b64encode(
json.dumps(state_payload, separators=(",", ":")).encode("utf-8")
)
signature = hmac.new(
_session_secret(settings),
f"telegram-oauth-state.{payload_part}".encode("ascii"),
hashlib.sha256,
).digest()
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
def verify_signed_telegram_oauth_state(
settings: Settings,
state: str,
) -> Optional[Dict[str, Any]]:
if not state or "." not in state:
return None
try:
payload_part, signature_part = state.split(".", 1)
expected_signature = hmac.new(
_session_secret(settings),
f"telegram-oauth-state.{payload_part}".encode("ascii"),
hashlib.sha256,
).digest()
received_signature = _urlsafe_b64decode(signature_part)
if not hmac.compare_digest(expected_signature, received_signature):
return None
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
now = int(time.time())
if int(payload.get("exp", 0)) < now:
return None
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
return None
return payload
except Exception as exc:
logger.debug("Failed to verify Telegram OAuth state: %s", exc)
return None
async def validate_telegram_oauth_id_token(
id_token: str,
*,
client_id: int,
expected_nonce: str,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram OIDC ID token and return a Telegram-like user payload."""
if not id_token or not client_id or not expected_nonce:
return None
try:
import jwt
from jwt import PyJWKClient
except Exception as exc:
logger.error(
"PyJWT is not installed; Telegram OAuth ID token validation is unavailable: %s",
exc,
)
return None
try:
jwks_client = PyJWKClient(TELEGRAM_OAUTH_JWKS_URL)
signing_key = await asyncio.to_thread(
jwks_client.get_signing_key_from_jwt,
id_token,
)
claims = await asyncio.to_thread(
jwt.decode,
id_token,
signing_key.key,
algorithms=TELEGRAM_OAUTH_ALGORITHMS,
audience=str(client_id),
issuer=TELEGRAM_OAUTH_ISSUER,
leeway=TELEGRAM_CLOCK_SKEW_SECONDS,
options={"require": ["exp", "iat", "iss", "aud"]},
)
if not hmac.compare_digest(str(claims.get("nonce") or ""), expected_nonce):
logger.warning("Telegram OAuth nonce mismatch.")
return None
now = int(time.time())
issued_at = int(claims.get("iat") or 0)
max_age = max(60, int(max_age_seconds))
if issued_at > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - issued_at > max_age:
logger.warning("Telegram OAuth ID token is stale.")
return None
telegram_id_raw = claims.get("id")
if not telegram_id_raw:
return None
telegram_id = int(telegram_id_raw)
full_name = str(claims.get("name") or "").strip()
first_name = str(claims.get("given_name") or "").strip()
last_name = str(claims.get("family_name") or "").strip()
if full_name and not first_name:
name_parts = full_name.split(None, 1)
first_name = name_parts[0]
if len(name_parts) > 1 and not last_name:
last_name = name_parts[1]
return {
"id": telegram_id,
"username": claims.get("preferred_username") or claims.get("username"),
"first_name": first_name or full_name or "Telegram",
"last_name": last_name,
"photo_url": claims.get("picture"),
"language_code": claims.get("locale"),
}
except Exception as exc:
logger.warning("Failed to validate Telegram OAuth ID token: %s", exc)
return None
def validate_telegram_webapp_init_data(
init_data: str,
bot_token: str,
+12 -3
View File
@@ -31,7 +31,8 @@ MAX_PROMO_CODE_INPUT_LENGTH = 100
async def prompt_promo_code_input(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings, session: AsyncSession):
settings: Settings, session: AsyncSession,
back_callback: str = "main_action:back_to_main"):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -52,14 +53,22 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
try:
await callback.message.edit_text(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
reply_markup=get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
))
except Exception as e_edit:
logging.warning(
f"Failed to edit message for promo prompt: {e_edit}. Sending new one."
)
await callback.message.answer(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
reply_markup=get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
))
await safe_answer_callback(callback)
await state.set_state(UserPromoStates.waiting_for_promo_code)
+64 -23
View File
@@ -9,17 +9,16 @@ from config.settings import Settings
from db.dal import user_dal
from bot.services.referral_service import ReferralService
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
from bot.middlewares.i18n import JsonI18n
router = Router(name="user_referral_router")
async def referral_command_handler(event: Union[types.Message,
types.CallbackQuery],
async def referral_command_handler(event: Union[types.Message, types.CallbackQuery],
settings: Settings, i18n_data: dict,
referral_service: ReferralService, bot: Bot,
session: AsyncSession):
session: AsyncSession,
back_callback: str = "main_action:back_to_main"):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -97,27 +96,35 @@ async def referral_command_handler(event: Union[types.Message,
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
"referral_no_bonuses_configured")
# Get referral statistics
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
webapp_referral_link = await _generate_webapp_referral_link(
session,
settings,
inviter_user_id,
)
webapp_link_section = (
_(
"referral_webapp_link_line",
webapp_referral_link=webapp_referral_link,
)
if webapp_referral_link
else ""
)
text = _("referral_program_info_new",
referral_link=referral_link,
webapp_link_section=webapp_link_section,
bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"])
if settings.SUBSCRIPTION_MINI_APP_URL:
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
webapp_referral_link = _build_webapp_referral_link(
settings.SUBSCRIPTION_MINI_APP_URL,
referral_code,
)
if webapp_referral_link:
webapp_label = "Web App ссылка" if current_lang == "ru" else "Web App link"
text += f"\n\n🔗 {webapp_label}:\n<code>{webapp_referral_link}</code>"
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
reply_markup_val = get_referral_link_keyboard(current_lang, i18n)
reply_markup_val = get_referral_link_keyboard(
current_lang,
i18n,
back_callback=back_callback,
)
if isinstance(event, types.Message):
await event.answer(text,
@@ -152,7 +159,7 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
bot_info = await bot.get_me()
bot_username = bot_info.username
if not bot_username:
await callback.answer("Ошибка получения имени бота", show_alert=True)
await callback.answer(_("error_generating_referral_link"), show_alert=True)
return
inviter_user_id = callback.from_user.id
@@ -166,18 +173,30 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
)
await callback.answer(_("error_generating_referral_link"), show_alert=True)
return
friend_message = _("referral_friend_message", referral_link=referral_link)
webapp_referral_link = await _generate_webapp_referral_link(
session,
settings,
inviter_user_id,
)
if webapp_referral_link:
friend_message = _(
"referral_friend_message_with_webapp",
referral_link=referral_link,
webapp_referral_link=webapp_referral_link,
)
else:
friend_message = _("referral_friend_message", referral_link=referral_link)
await callback.message.answer(
friend_message,
disable_web_page_preview=True
)
except Exception as e:
logging.error(f"Error in referral share message: {e}")
await callback.answer("Произошла ошибка", show_alert=True)
await callback.answer(_("error_occurred_try_again"), show_alert=True)
await callback.answer()
@@ -196,3 +215,25 @@ def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional
parts.fragment,
)
)
async def _generate_webapp_referral_link(
session: AsyncSession,
settings: Settings,
inviter_user_id: int,
) -> Optional[str]:
if not settings.SUBSCRIPTION_MINI_APP_URL:
return None
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
return _build_webapp_referral_link(
settings.SUBSCRIPTION_MINI_APP_URL,
referral_code,
)
@router.message(Command("referral"))
async def referral_command_message_handler(message: types.Message, settings: Settings,
i18n_data: dict, referral_service: ReferralService,
bot: Bot, session: AsyncSession):
await referral_command_handler(message, settings, i18n_data, referral_service, bot, session)
+88 -9
View File
@@ -152,6 +152,11 @@ async def send_bot_interface_menu(
settings, subscription_service, session, user_id)
text = i18n.gettext(current_lang, "bot_interface_menu_title")
if settings.SUBSCRIPTION_MINI_APP_URL:
text = (
f"{text}\n\n"
f"{i18n.gettext(current_lang, 'bot_interface_menu_webapp_hint')}"
)
reply_markup = get_bot_interface_inline_keyboard(
current_lang, i18n, settings, show_trial_button_in_menu)
@@ -797,6 +802,7 @@ async def language_command_handler(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
settings: Settings,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -804,7 +810,11 @@ async def language_command_handler(
) if i18n else key
text_to_send = _(key="choose_language")
reply_markup = get_language_selection_keyboard(i18n, current_lang)
reply_markup = get_language_selection_keyboard(
i18n,
current_lang,
back_callback=back_callback,
)
target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
@@ -845,7 +855,8 @@ async def select_language_callback_handler(
return
try:
lang_code = callback.data.split("_")[2]
lang_payload = callback.data.split("_", 2)[2]
lang_code, _, return_target = lang_payload.partition(":")
except IndexError:
await safe_answer_callback(
callback,
@@ -879,12 +890,20 @@ async def select_language_callback_handler(
exc_info=True)
await safe_answer_callback(callback, "Error setting language.", show_alert=True)
return
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
if return_target == "bot":
await send_bot_interface_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
else:
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
@router.callback_query(F.data.startswith("main_action:"))
@@ -896,6 +915,9 @@ async def main_action_callback_handler(
action = callback.data.split(":")[1]
user_id = callback.from_user.id
if action in {"back_to_main", "back_to_main_keep", "bot_interface"}:
await state.clear()
from . import subscription as user_subscription_handlers
from . import referral as user_referral_handlers
from . import promo_user as user_promo_handlers
@@ -912,10 +934,29 @@ async def main_action_callback_handler(
if action == "subscribe":
await user_subscription_handlers.display_subscription_options(
callback, i18n_data, settings, session)
elif action == "bot_subscribe":
await user_subscription_handlers.display_subscription_options(
callback,
i18n_data,
settings,
session,
back_callback="main_action:bot_interface",
)
elif action == "my_subscription":
await user_subscription_handlers.my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
session, bot)
elif action == "bot_my_subscription":
await user_subscription_handlers.my_subscription_command_handler(
callback,
i18n_data,
settings,
panel_service,
subscription_service,
session,
bot,
back_callback="main_action:bot_interface",
)
elif action == "my_devices":
await user_subscription_handlers.my_devices_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
@@ -923,16 +964,49 @@ async def main_action_callback_handler(
elif action == "referral":
await user_referral_handlers.referral_command_handler(
callback, settings, i18n_data, referral_service, bot, session)
elif action == "bot_referral":
await user_referral_handlers.referral_command_handler(
callback,
settings,
i18n_data,
referral_service,
bot,
session,
back_callback="main_action:bot_interface",
)
elif action == "apply_promo":
await user_promo_handlers.prompt_promo_code_input(
callback, state, i18n_data, settings, session)
elif action == "bot_apply_promo":
await user_promo_handlers.prompt_promo_code_input(
callback,
state,
i18n_data,
settings,
session,
back_callback="main_action:bot_interface",
)
elif action == "request_trial":
await user_trial_handlers.request_trial_confirmation_handler(
callback, settings, i18n_data, subscription_service, session)
elif action == "language":
await language_command_handler(callback, i18n_data, settings)
elif action == "info":
elif action == "bot_language":
await language_command_handler(
callback,
i18n_data,
settings,
back_callback="main_action:bot_interface",
)
elif action == "bot_interface":
await send_bot_interface_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
elif action in {"info", "bot_info"}:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE)
@@ -962,6 +1036,11 @@ async def main_action_callback_handler(
i18n,
privacy_url,
user_agreement_url,
back_callback=(
"main_action:bot_interface"
if callback.data == "main_action:bot_info"
else "main_action:back_to_main"
),
)
try:
await callback.message.edit_text(_(key="info_links_message"),
+29 -5
View File
@@ -39,7 +39,13 @@ def _hwid_callback_token(hwid: Optional[str]) -> str:
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
async def display_subscription_options(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
settings: Settings,
session: AsyncSession,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -75,11 +81,20 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
if options:
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
reply_markup = get_subscription_options_keyboard(
options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
options,
currency_symbol_val,
current_lang,
i18n,
traffic_mode=traffic_mode,
back_callback=back_callback,
)
else:
text_content = get_text("no_subscription_options_available")
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
reply_markup = get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
)
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
@@ -116,6 +131,7 @@ async def my_subscription_command_handler(
subscription_service: SubscriptionService,
session: AsyncSession,
bot: Bot,
back_callback: str = "main_action:back_to_main",
):
target = event.message if isinstance(event, types.CallbackQuery) else event
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -139,7 +155,11 @@ async def my_subscription_command_handler(
buy_button = InlineKeyboardButton(
text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe"
)
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
back_markup = get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
)
kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard])
@@ -231,7 +251,11 @@ async def my_subscription_command_handler(
traffic_period=period_label,
)
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
base_markup = get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
)
kb = base_markup.inline_keyboard
try:
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
+35 -16
View File
@@ -28,11 +28,21 @@ def get_main_menu_inline_keyboard(
)
)
builder.row(
InlineKeyboardButton(text=_(key="menu_bot_interface_button"),
callback_data="main_action:bot_interface"))
if settings.SUPPORT_LINK:
builder.row(
InlineKeyboardButton(text=_(key="menu_support_button"),
url=settings.SUPPORT_LINK))
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if settings.PRIVACY_POLICY_URL or user_agreement_url:
builder.row(
InlineKeyboardButton(text=_(key="menu_info_button"),
callback_data="main_action:info"))
return builder.as_markup()
@@ -59,26 +69,26 @@ def get_bot_interface_inline_keyboard(
builder.row(
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe"))
callback_data="main_action:bot_subscribe"))
builder.row(
InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
callback_data="main_action:my_subscription",
callback_data="main_action:bot_my_subscription",
)
)
referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"),
callback_data="main_action:referral")
callback_data="main_action:bot_referral")
promo_button = InlineKeyboardButton(
text=_(key="menu_apply_promo_button"),
callback_data="main_action:apply_promo")
callback_data="main_action:bot_apply_promo")
builder.row(referral_button)
builder.row(promo_button)
language_button = InlineKeyboardButton(
text=_(key="menu_language_settings_inline"),
callback_data="main_action:language")
callback_data="main_action:bot_language")
status_button_list = []
if settings.SERVER_STATUS_URL:
status_button_list.append(
@@ -99,7 +109,11 @@ def get_bot_interface_inline_keyboard(
if settings.PRIVACY_POLICY_URL or user_agreement_url:
builder.row(
InlineKeyboardButton(text=_(key="menu_info_button"),
callback_data="main_action:info"))
callback_data="main_action:bot_info"))
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main"))
return builder.as_markup()
@@ -108,7 +122,8 @@ def get_information_links_keyboard(
lang: str,
i18n_instance,
privacy_policy_url: Optional[str],
user_agreement_url: Optional[str]) -> InlineKeyboardMarkup:
user_agreement_url: Optional[str],
back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if privacy_policy_url:
@@ -121,21 +136,23 @@ def get_information_links_keyboard(
url=user_agreement_url))
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main"))
callback_data=back_callback))
return builder.as_markup()
def get_language_selection_keyboard(i18n_instance,
current_lang: str) -> InlineKeyboardMarkup:
current_lang: str,
back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
)
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
builder = InlineKeyboardBuilder()
builder.button(text=f"🇬🇧 English {'' if current_lang == 'en' else ''}",
callback_data="set_lang_en")
callback_data=f"set_lang_en{callback_suffix}")
builder.button(text=f"🇷🇺 Русский {'' if current_lang == 'ru' else ''}",
callback_data="set_lang_ru")
callback_data=f"set_lang_ru{callback_suffix}")
builder.button(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main")
callback_data=back_callback)
builder.adjust(1)
return builder.as_markup()
@@ -154,7 +171,8 @@ def get_trial_confirmation_keyboard(lang: str,
def get_subscription_options_keyboard(subscription_options: Dict[
float, Optional[float]], currency_symbol_val: str, lang: str,
i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup:
i18n_instance, traffic_mode: bool = False,
back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
def _format_gb(val: float) -> str:
@@ -181,7 +199,7 @@ def get_subscription_options_keyboard(subscription_options: Dict[
builder.adjust(1)
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main"))
callback_data=back_callback))
return builder.as_markup()
@@ -365,13 +383,14 @@ def get_yk_saved_cards_keyboard(
def get_referral_link_keyboard(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
i18n_instance,
back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="referral_share_message_button"),
callback_data="referral_action:share_message")
builder.button(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main")
callback_data=back_callback)
builder.adjust(1)
return builder.as_markup()
+123 -15
View File
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import security_dal
from db.models import EmailVerificationCode
from bot.services.email_templates import EmailContent, render_login_code
logger = logging.getLogger(__name__)
@@ -45,6 +46,15 @@ class EmailCodeVerifyResult:
retry_after: Optional[int] = None
@dataclass(frozen=True)
class EmailMagicVerifyResult:
ok: bool
error: Optional[str] = None
email: Optional[str] = None
purpose: Optional[str] = None
target_user_id: Optional[int] = None
def normalize_email(value: str) -> str:
return (value or "").strip().lower()
@@ -97,6 +107,31 @@ class EmailAuthService:
payload = f"{purpose}:{email}:{code}".encode("utf-8")
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
def _hash_magic_token(self, token: str) -> str:
secret = hmac.new(
self.settings.BOT_TOKEN.encode("utf-8"),
b"remnawave-tg-shop-email-magic",
hashlib.sha256,
).digest()
return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest()
def _build_magic_link(self, *, token: str, purpose: str) -> Optional[str]:
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
if not base_url:
return None
from urllib.parse import urlencode, urlsplit, urlunsplit
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
params = {"login_token": token}
if purpose and purpose != "login":
params["login_purpose"] = purpose
existing_query = parsed.query
new_query = urlencode(params)
merged_query = f"{existing_query}&{new_query}" if existing_query else new_query
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, merged_query, parsed.fragment))
async def request_code(
self,
session: AsyncSession,
@@ -158,9 +193,12 @@ class EmailAuthService:
)
code = f"{secrets.randbelow(1_000_000):06d}"
magic_token = secrets.token_urlsafe(32)
magic_link = self._build_magic_link(token=magic_token, purpose=purpose)
code_model = EmailVerificationCode(
email=normalized_email,
code_hash=self._hash_code(normalized_email, purpose, code),
magic_token_hash=self._hash_magic_token(magic_token) if magic_link else None,
purpose=purpose,
target_user_id=target_user_id,
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
@@ -173,6 +211,7 @@ class EmailAuthService:
email=normalized_email,
code=code,
language_code=language_code,
magic_link=magic_link,
)
return EmailCodeRequestResult(ok=True)
@@ -303,18 +342,74 @@ class EmailAuthService:
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def verify_magic_token(
self,
session: AsyncSession,
*,
token: str,
purpose: str,
target_user_id: Optional[int] = None,
) -> EmailMagicVerifyResult:
if not token:
return EmailMagicVerifyResult(ok=False, error="invalid_token")
token_hash = self._hash_magic_token(token)
now = datetime.now(timezone.utc)
stmt = (
select(EmailVerificationCode)
.where(
EmailVerificationCode.magic_token_hash == token_hash,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.target_user_id == target_user_id,
EmailVerificationCode.status == "active",
EmailVerificationCode.consumed_at.is_(None),
)
.limit(1)
)
result = await session.execute(stmt)
record = result.scalar_one_or_none()
if not record:
return EmailMagicVerifyResult(ok=False, error="invalid_token")
expires_at = record.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
return EmailMagicVerifyResult(ok=False, error="expired_token")
record.consumed_at = now
throttle_identifier = _email_throttle_identifier(
record.email,
purpose,
target_user_id,
)
await security_dal.clear_throttle_state(
session,
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
identifier=throttle_identifier,
)
await session.flush()
return EmailMagicVerifyResult(
ok=True,
email=record.email,
purpose=record.purpose,
target_user_id=record.target_user_id,
)
async def _send_code_email(
self,
*,
email: str,
code: str,
language_code: str,
magic_link: Optional[str] = None,
) -> None:
await asyncio.to_thread(
self._send_code_email_sync,
email=email,
code=code,
language_code=language_code,
magic_link=magic_link,
)
async def send_custom_email(
@@ -323,12 +418,27 @@ class EmailAuthService:
email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
) -> None:
await asyncio.to_thread(
self._send_custom_email_sync,
email=email,
subject=subject,
body=body,
html_body=html_body,
)
async def send_rendered_email(
self,
*,
email: str,
content: EmailContent,
) -> None:
await self.send_custom_email(
email=email,
subject=content.subject,
body=content.text,
html_body=content.html,
)
def _send_code_email_sync(
@@ -337,23 +447,17 @@ class EmailAuthService:
email: str,
code: str,
language_code: str,
magic_link: Optional[str] = None,
) -> None:
lang = (language_code or self.settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
if lang == "en":
subject = "Your login code"
body = (
f"Your verification code: {code}\n\n"
f"The code expires in {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} minutes."
)
else:
subject = "Код подтверждения"
body = (
f"Ваш код подтверждения: {code}\n\n"
f"Код действует {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} мин."
)
content = render_login_code(
self.settings,
code=code,
language_code=language_code,
magic_link=magic_link,
)
message = EmailMessage()
message["Subject"] = subject
message["Subject"] = content.subject
message["From"] = formataddr(
(
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
@@ -361,7 +465,8 @@ class EmailAuthService:
)
)
message["To"] = email
message.set_content(body)
message.set_content(content.text)
message.add_alternative(content.html, subtype="html")
context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST
@@ -411,6 +516,7 @@ class EmailAuthService:
email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
) -> None:
message = EmailMessage()
message["Subject"] = subject
@@ -422,6 +528,8 @@ class EmailAuthService:
)
message["To"] = email
message.set_content(body)
if html_body:
message.add_alternative(html_body, subtype="html")
context = ssl.create_default_context()
smtp_host = self.settings.SMTP_HOST
+484
View File
@@ -0,0 +1,484 @@
"""Branded HTML email templates that mirror the subscription Mini App look.
The web app uses a dark theme with a configurable accent colour
(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same
accent + logo are reused here so emails feel like part of the product. All
copy goes through the shared `JsonI18n` instance so translations live in
``locales/<lang>.json`` next to the rest of the bot strings.
"""
from __future__ import annotations
import html
import re
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
from urllib.parse import urlsplit
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
from config.settings import Settings
_BG = "#05070a"
_CARD_BG = "#0e1116"
_BORDER = "#1a1f27"
_TEXT = "#e6e9ef"
_TEXT_MUTED = "#9aa3b2"
_TEXT_DIM = "#5d6573"
_DEFAULT_ACCENT = "#00fe7a"
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
@dataclass(frozen=True)
class EmailContent:
subject: str
text: str
html: str
def _safe_color(value: Optional[str]) -> str:
if not value:
return _DEFAULT_ACCENT
candidate = value.strip()
if _HEX_RE.match(candidate):
return candidate
return _DEFAULT_ACCENT
def _public_logo_url(settings: Settings) -> Optional[str]:
"""Email recipients can't reach the in-app /webapp-logo proxy, so the
raw https URL from the env is used directly. Anything else is dropped."""
raw = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw:
return None
parsed = urlsplit(raw)
if parsed.scheme != "https" or not parsed.hostname:
return None
return raw
def _brand_title(settings: Settings) -> str:
title = (settings.WEBAPP_TITLE or "").strip()
return title or "Subscription"
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
return i18n or get_i18n_instance()
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
"""Translate for HTML context: format args are HTML-escaped, the
translated template itself is treated as already-safe HTML (locale files
are author-controlled and may include simple inline tags like <strong>)."""
safe_kwargs = {k: html.escape(str(v)) for k, v in kwargs.items()}
return i18n.gettext(lang, key, **safe_kwargs)
def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
return i18n.gettext(lang, key, **kwargs)
def _layout(
*,
settings: Settings,
preheader: str,
heading: str,
intro_html: str,
body_html: str,
footer_html: str,
) -> str:
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand_title = html.escape(_brand_title(settings))
logo_url = _public_logo_url(settings)
logo_block = ""
if logo_url:
logo_block = (
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
f'border-radius:16px;">'
)
return f"""<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
<title>{html.escape(heading)}</title>
</head>
<body style="margin:0;padding:0;background:{_BG};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{_TEXT};">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">{html.escape(preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{_BG};">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
<tr>
<td align="center" style="padding-bottom:24px;">
{logo_block}
<div style="margin-top:14px;font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-weight:800;font-size:22px;line-height:1.05;color:{accent};letter-spacing:0;">{brand_title}</div>
</td>
</tr>
<tr>
<td style="background:{_CARD_BG};border:1px solid {_BORDER};border-radius:18px;padding:28px;">
<h1 style="margin:0 0 10px 0;font-size:20px;line-height:1.25;font-weight:700;color:#ffffff;">{html.escape(heading)}</h1>
<div style="margin:0 0 20px 0;font-size:14px;line-height:1.55;color:{_TEXT_MUTED};">{intro_html}</div>
{body_html}
</td>
</tr>
<tr>
<td align="center" style="padding-top:20px;">
<div style="font-size:11px;line-height:1.55;color:{_TEXT_DIM};">{footer_html}</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
if not rows:
return ""
last = len(rows) - 1
cells = []
for index, (label, value) in enumerate(rows):
border = "" if index == last else f"border-bottom:1px solid {_BORDER};"
cells.append(
f'<tr>'
f'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>'
f'<td align="right" style="padding:11px 0;{border}font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};">{html.escape(value)}</td>'
f'</tr>'
)
return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">'
+ "".join(cells)
+ "</table>"
)
def _cta_button_html(*, label: str, url: str, accent: str) -> str:
safe_label = html.escape(label)
safe_url = html.escape(url, quote=True)
# Accent green is light, so contrast text is dark; works for the default and similar light accents.
return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;margin:22px 0 18px 0;">'
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">'
f'<a href="{safe_url}" target="_blank" rel="noopener" '
f'style="display:block;width:100%;box-sizing:border-box;padding:15px 22px;'
f'font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;'
f'font-size:15px;font-weight:700;color:#05070a;text-decoration:none;letter-spacing:0.02em;text-align:center;">{safe_label}</a>'
f'</td></tr></table>'
)
def _format_amount(amount: float, currency: str) -> str:
rounded = round(float(amount), 2)
if rounded.is_integer():
body = f"{int(rounded)}"
else:
body = f"{rounded:.2f}"
suffix = (currency or "").strip()
return f"{body} {suffix}".strip()
def _format_traffic(traffic_gb: Optional[float]) -> str:
if traffic_gb is None:
return ""
value = float(traffic_gb)
return str(int(value)) if value.is_integer() else f"{value:g}"
def _format_minutes(seconds: int) -> int:
return max(1, int(seconds) // 60)
def render_login_code(
settings: Settings,
*,
code: str,
language_code: Optional[str],
magic_link: Optional[str] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_magic_link = (magic_link or "").strip()
subject = _t_text(i18n, lang, "email_login_code_subject", code=code)
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
heading = _t_text(i18n, lang, "email_login_code_heading")
intro = _t_text(i18n, lang, "email_login_code_intro")
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
security = _t_text(i18n, lang, "email_login_code_security")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)]
if safe_magic_link:
text_lines.append(
_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)
)
code_block = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">'
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">'
f'<div style="font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">'
f'{html.escape(code)}'
f'</div></td></tr></table>'
)
magic_block = ""
if safe_magic_link:
cta_label = _t_text(i18n, lang, "email_login_code_magic_cta")
divider_label = _t_text(i18n, lang, "email_login_code_magic_or")
magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro")
magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint")
divider_html = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">'
f'<tr>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'</tr></table>'
)
magic_block = (
divider_html
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>'
+ _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent)
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>'
)
body_html = (
code_block
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>'
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>'
+ magic_block
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html=body_html,
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def render_account_merged(
settings: Settings,
*,
language_code: Optional[str],
primary_user_id: Optional[int],
removed_user_id: Optional[int],
final_end_date_text: str,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
brand = _brand_title(settings)
primary = "" if primary_user_id is None else f"#{primary_user_id}"
removed = "" if removed_user_id is None else f"#{removed_user_id}"
end_date = final_end_date_text or ""
subject = _t_text(i18n, lang, "email_account_merged_subject")
preheader = _t_text(i18n, lang, "email_account_merged_preheader")
heading = _t_text(i18n, lang, "email_account_merged_heading")
intro = _t_text(i18n, lang, "email_account_merged_intro")
note = _t_text(i18n, lang, "email_account_merged_note")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
text = _t_text(
i18n,
lang,
"email_account_merged_text",
primary=primary,
removed=removed,
end_date=end_date,
)
rows = [
(_t_text(i18n, lang, "email_account_merged_row_kept"), primary),
(_t_text(i18n, lang, "email_account_merged_row_removed"), removed),
(_t_text(i18n, lang, "email_account_merged_row_end_date"), end_date),
]
body_html = (
_info_rows_html(rows)
+ f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html=body_html,
footer_html=footer,
)
return EmailContent(subject=subject, text=text, html=rendered)
def render_payment_success(
settings: Settings,
*,
language_code: Optional[str],
sale_mode: str,
months: int,
traffic_gb: Optional[float],
amount: float,
currency: str,
end_date_text: str,
dashboard_url: Optional[str],
provider_label: Optional[str] = None,
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
is_traffic = sale_mode == "traffic"
amount_text = _format_amount(amount, currency)
safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or ""
traffic_label = _format_traffic(traffic_gb)
subject = _t_text(i18n, lang, "email_payment_success_subject")
preheader = _t_text(i18n, lang, "email_payment_success_preheader")
heading = _t_text(i18n, lang, "email_payment_success_heading")
footer_note = _t_text(i18n, lang, "email_payment_success_footer_note")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
if is_traffic:
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
period_value = _t_text(i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label)
text = _t_text(
i18n,
lang,
"email_payment_success_text_traffic",
amount=amount_text,
traffic_gb=traffic_label,
end_date=end_date,
)
else:
months_int = int(months or 0)
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
period_label = _t_text(i18n, lang, "email_payment_success_row_period")
period_value = _t_text(
i18n,
lang,
"email_payment_success_period_value",
months=months_int,
)
text = _t_text(
i18n,
lang,
"email_payment_success_text_subscription",
amount=amount_text,
months=months_int,
end_date=end_date,
)
rows: list[Tuple[str, str]] = [
(period_label, period_value),
(_t_text(i18n, lang, "email_payment_success_row_amount"), amount_text),
(_t_text(i18n, lang, "email_payment_success_row_end_date"), end_date),
]
if provider_label:
rows.append((_t_text(i18n, lang, "email_payment_success_row_method"), provider_label))
text_lines = [text]
if safe_dashboard_url:
text_lines.append(_t_text(i18n, lang, "email_payment_success_text_dashboard", url=safe_dashboard_url))
body_parts = [_info_rows_html(rows)]
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>'
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def render_subscription_expiring(
settings: Settings,
*,
language_code: Optional[str],
days_left: int,
end_date_text: str,
dashboard_url: Optional[str],
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip()
days = max(0, int(days_left))
end_date = end_date_text or ""
if days == 0:
suffix = "today"
elif days == 1:
suffix = "tomorrow"
else:
suffix = "days"
subject = _t_text(i18n, lang, f"email_subscription_expiring_subject_{suffix}", days=days)
heading = _t_text(i18n, lang, f"email_subscription_expiring_heading_{suffix}", days=days)
preheader = _t_text(i18n, lang, f"email_subscription_expiring_preheader_{suffix}", days=days)
intro = _t_text(i18n, lang, f"email_subscription_expiring_intro_{suffix}", days=days)
note = _t_text(i18n, lang, "email_subscription_expiring_note")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_subscription_expiring_cta")
rows = [
(_t_text(i18n, lang, "email_subscription_expiring_row_days_left"), str(days)),
(_t_text(i18n, lang, "email_subscription_expiring_row_end_date"), end_date),
]
text_lines = [
_t_text(i18n, lang, "email_subscription_expiring_text", heading=heading, end_date=end_date),
]
if safe_dashboard_url:
text_lines.append(
_t_text(i18n, lang, "email_subscription_expiring_text_renew", url=safe_dashboard_url)
)
body_parts = [_info_rows_html(rows)]
if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
)
rendered = _layout(
settings=settings,
preheader=preheader,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
+112 -1
View File
@@ -212,7 +212,118 @@ class NotificationService:
# Send to log channel
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_new_email_user_registration(
self,
user_id: int,
email: str,
referred_by_id: Optional[int] = None,
):
"""Send notification about new user registration via email (Web App)."""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
referral_text = ""
if referred_by_id:
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
referral_text = _(
"log_referral_suffix",
referrer_link=referrer_link,
)
message = _(
"log_new_email_user_registration",
user_id=user_id,
email=hd.quote(email),
referral_text=referral_text,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
# Email users have a synthetic (negative) user_id with no Telegram profile,
# so we only attach the referrer button when a real referrer is present.
reply_markup: Optional[InlineKeyboardMarkup] = None
if referred_by_id and referred_by_id > 0:
reply_markup = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(
text=_("log_open_referrer_profile_button"),
url=f"tg://user?id={referred_by_id}",
)
]])
await self._send_to_log_channel(message, reply_markup=reply_markup)
async def notify_account_email_linked(
self,
user_id: int,
email: str,
telegram_id: Optional[int] = None,
username: Optional[str] = None,
first_name: Optional[str] = None,
):
"""Send notification when an email is linked to a Telegram-created account."""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=telegram_id or user_id,
username=username,
first_name=first_name,
)
message = _(
"log_account_email_linked",
user_id=user_id,
telegram_id=telegram_id or user_id,
user_display=user_display,
email=hd.quote(email),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
reply_markup: Optional[InlineKeyboardMarkup] = None
if telegram_id and telegram_id > 0:
reply_markup = self._build_profile_keyboard(_, telegram_id)
await self._send_to_log_channel(message, reply_markup=reply_markup)
async def notify_account_telegram_linked(
self,
user_id: int,
email: Optional[str],
telegram_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
):
"""Send notification when Telegram is linked to an email-created account."""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=telegram_id,
username=username,
first_name=first_name,
)
message = _(
"log_account_telegram_linked",
user_id=user_id,
telegram_id=telegram_id,
user_display=user_display,
email=hd.quote(email or ""),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
months: int, payment_provider: str,
username: Optional[str] = None,
+34
View File
@@ -9,6 +9,8 @@ from sqlalchemy.orm import sessionmaker
from typing import Optional
from config.settings import Settings
from .panel_api_service import PanelApiService
from .email_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring
from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
from db.dal import user_dal
@@ -64,6 +66,7 @@ class PanelWebhookService:
internal_user_id = db_user.user_id if db_user else user_id
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
user_email = (db_user.email or "").strip() if db_user else ""
markup = get_subscribe_only_markup(lang, self.i18n)
@@ -122,6 +125,13 @@ class PanelWebhookService:
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
if days_left == 3 and user_email:
await self._send_subscription_expiring_email(
recipient=user_email,
lang=lang,
days_left=days_left,
end_date_text=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired":
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
@@ -142,6 +152,30 @@ class PanelWebhookService:
end_date=user_payload.get("expireAt", "")[:10],
)
async def _send_subscription_expiring_email(
self,
*,
recipient: str,
lang: str,
days_left: int,
end_date_text: str,
) -> None:
"""Best-effort branded reminder; silently no-ops without SMTP config."""
if not self.settings.email_auth_configured:
return
try:
content = render_subscription_expiring(
self.settings,
language_code=lang,
days_left=days_left,
end_date_text=end_date_text,
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
)
email_service = EmailAuthService(self.settings)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception("Failed to send subscription-expiring email to %s", recipient)
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
if not self.settings.PANEL_WEBHOOK_SECRET:
return web.Response(status=401, text="unauthorized")
+79
View File
@@ -12,6 +12,8 @@ from db.models import User, Subscription
from config.settings import Settings
from .panel_api_service import PanelApiService
from .email_auth_service import EmailAuthService
from .email_templates import render_payment_success
class SubscriptionService:
@@ -425,6 +427,7 @@ class SubscriptionService:
"is_active": True,
"status_from_panel": "TRIAL",
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY,
"auto_renew_enabled": False,
}
try:
@@ -446,6 +449,7 @@ class SubscriptionService:
expire_at=end_date,
status="ACTIVE",
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
@@ -574,6 +578,16 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="traffic",
months=0,
traffic_gb=float(traffic_gb),
payment_amount=payment_amount,
end_date=None,
provider=provider,
)
return {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
@@ -736,6 +750,16 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="subscription",
months=months_int,
traffic_gb=None,
payment_amount=payment_amount,
end_date=final_end_date,
provider=provider,
)
return {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
@@ -1067,6 +1091,61 @@ class SubscriptionService:
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
return True
_PROVIDER_LABELS = {
"yookassa": "YooKassa",
"freekassa": "FreeKassa",
"platega": "Platega",
"severpay": "SeverPay",
"cryptopay": "Crypto Pay",
"crypto_pay": "Crypto Pay",
"stars": "Telegram Stars",
"tribute": "Tribute",
}
async def _send_payment_success_email(
self,
*,
db_user: User,
sale_mode: str,
months: int,
traffic_gb: Optional[float],
payment_amount: float,
end_date: Optional[datetime],
provider: str,
) -> None:
"""Best-effort branded email confirming the payment. No-op if SMTP or
the user's email aren't set. Failures are logged and swallowed so the
payment flow is never blocked by mail delivery."""
if not self.settings.email_auth_configured:
return
recipient = (db_user.email or "").strip() if db_user else ""
if not recipient:
return
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
try:
content = render_payment_success(
self.settings,
language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE,
sale_mode=sale_mode,
months=int(months or 0),
traffic_gb=traffic_gb,
amount=float(payment_amount or 0),
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
end_date_text=end_date_text,
dashboard_url=dashboard_url,
provider_label=provider_label,
)
email_service = EmailAuthService(self.settings)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception(
"Failed to send payment success email to user %s", db_user.user_id
)
async def update_last_notification_sent(
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
):
+18
View File
@@ -86,6 +86,7 @@ class WebAppSettings(BaseModel):
title: str
primary_color: str
logo_url: Optional[str]
logo_emoji: str
session_ttl_seconds: int
session_secret: str
webhook_secret_token: str
@@ -313,6 +314,7 @@ class Settings(BaseSettings):
TRIAL_ENABLED: bool = Field(default=True)
TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs")
CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled")
@@ -329,11 +331,24 @@ class Settings(BaseSettings):
WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
TELEGRAM_OAUTH_CLIENT_ID: Optional[int] = Field(
default=None,
description="Telegram Web Login Client ID from BotFather. Defaults to the numeric bot ID from BOT_TOKEN.",
)
TELEGRAM_OAUTH_CLIENT_SECRET: Optional[str] = Field(
default=None,
description="Telegram Web Login Client Secret from BotFather. Reserved for full OIDC authorization code integrations.",
)
TELEGRAM_OAUTH_REQUEST_ACCESS: Optional[str] = Field(
default="write",
description="Comma-separated Telegram Login permissions to request: write,phone. Leave empty to request only OpenID profile.",
)
SMTP_HOST: str = Field(default="smtp-relay.brevo.com")
SMTP_PORT: int = Field(default=587)
@@ -473,6 +488,7 @@ class Settings(BaseSettings):
title=self.WEBAPP_TITLE,
primary_color=self.WEBAPP_PRIMARY_COLOR,
logo_url=self.WEBAPP_LOGO_URL,
logo_emoji=self.WEBAPP_LOGO_EMOJI,
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
session_secret=self.WEBAPP_SESSION_SECRET,
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
@@ -891,6 +907,8 @@ class Settings(BaseSettings):
'USER_AGREEMENT_URL',
'SUBSCRIPTION_MINI_APP_URL',
'WEBAPP_LOGO_URL',
'TELEGRAM_OAUTH_CLIENT_SECRET',
'TELEGRAM_OAUTH_REQUEST_ACCESS',
'SMTP_USERNAME',
'SMTP_PASSWORD',
'SMTP_FROM_EMAIL',
+26
View File
@@ -282,6 +282,27 @@ def _migration_0008_add_email_verification_code_status(connection: Connection) -
)
def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
if "magic_token_hash" not in columns:
connection.execute(
text(
"ALTER TABLE email_verification_codes ADD COLUMN magic_token_hash VARCHAR"
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_magic_token_hash
ON email_verification_codes (magic_token_hash)
"""
)
)
def _migration_0009_add_composite_indexes(connection: Connection) -> None:
connection.execute(
text(
@@ -355,6 +376,11 @@ MIGRATIONS: List[Migration] = [
description="Add composite indexes for subscription and payment lookups",
upgrade=_migration_0009_add_composite_indexes,
),
Migration(
id="0010_add_email_magic_token_hash",
description="Store hashed magic-link tokens for email login deeplinks",
upgrade=_migration_0010_add_email_magic_token_hash,
),
]
+1
View File
@@ -100,6 +100,7 @@ class EmailVerificationCode(Base):
code_id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String, nullable=False, index=True)
code_hash = Column(String, nullable=False)
magic_token_hash = Column(String, nullable=True, index=True)
purpose = Column(String, nullable=False, index=True)
target_user_id = Column(
BigInteger,
+125
View File
@@ -0,0 +1,125 @@
# Тарифы
В Minishop два режима тарификации, и они **взаимоисключающие**: бот в каждый момент времени продаёт либо подписку на срок (1 / 3 / 6 / 12 месяцев), либо пакеты трафика. Ниже — что именно настраивается, что происходит при покупке и как переключаться.
## Краткое сравнение
| | Подписка по времени | Пакеты трафика |
| --- | --- | --- |
| Что покупает пользователь | Период доступа (1/3/6/12 мес.) | Объём трафика в ГБ |
| Срок действия в панели | До конца купленного периода | До 01.01.2099 (фактически бессрочно) |
| Лимит трафика | Из `USER_TRAFFIC_LIMIT_GB`, сбрасывается по `USER_TRAFFIC_STRATEGY` | Кумулятивно: каждый платёж **прибавляет** ГБ к лимиту |
| Стратегия сброса | `NO_RESET` / `WEEK` / `MONTH` / `DAY` | Принудительно `NO_RESET` |
| Автопродление YooKassa | Поддерживается | Выключено |
| Реферальные бонусы | Доступны (бонусные дни) | Не выдаются |
| Триал | Доступен (`TRIAL_*`) | Доступен (включается отдельно) |
Бот определяет режим по наличию переменных `TRAFFIC_PACKAGES` или `STARS_TRAFFIC_PACKAGES`: если хотя бы одна из них непустая — включается режим продажи трафика и переменные `*_MONTHS_ENABLED` / `RUB_PRICE_*` игнорируются.
## Режим «Подписка по времени» (по умолчанию)
Используется, когда `TRAFFIC_PACKAGES` и `STARS_TRAFFIC_PACKAGES` пусты.
### Цены и периоды
Для каждого периода настраивается доступность и две цены — в основной валюте (`DEFAULT_CURRENCY_SYMBOL`, по умолчанию RUB) и в Telegram Stars:
| Переменная | Описание |
| --- | --- |
| `1_MONTH_ENABLED` / `3_MONTHS_ENABLED` / `6_MONTHS_ENABLED` / `12_MONTHS_ENABLED` | `true`/`false` — показывать ли период в выборе |
| `RUB_PRICE_1_MONTH` / `RUB_PRICE_3_MONTHS` / `RUB_PRICE_6_MONTHS` / `RUB_PRICE_12_MONTHS` | Цена в рублях. Если не задана — кнопка не появится |
| `STARS_PRICE_1_MONTH` / `STARS_PRICE_3_MONTHS` / `STARS_PRICE_6_MONTHS` / `STARS_PRICE_12_MONTHS` | Цена в Stars. Используется, когда оплата идёт через `STARS_ENABLED` |
Если `RUB_PRICE_*` равно `0` — соответствующий период просто скрывается.
### Трафик пользователя
Лимит трафика и стратегия его сброса настраиваются глобально и применяются ко всем платным пользователям (в том числе при продлении):
| Переменная | Описание |
| --- | --- |
| `USER_TRAFFIC_LIMIT_GB` | Лимит трафика в ГБ. `0` — безлимит |
| `USER_TRAFFIC_STRATEGY` | Когда сбрасывается счётчик: `NO_RESET`, `DAY`, `WEEK`, `MONTH` |
### Что происходит при оплате
1. Если у пользователя ещё нет активной подписки — стартовая дата = «сейчас». Если есть — новая длительность прибавляется к её `end_date` (продление, а не перезапись).
2. К итогу могут добавиться бонусные дни промокода (`promo_codes`) и реферальной программы (`REFERRAL_BONUS_DAYS_*`, `REFEREE_BONUS_DAYS_*`).
3. В панели Remnawave у пользователя обновляются `expireAt`, `trafficLimitBytes` и `trafficLimitStrategy` под текущие настройки.
### Автопродление (только YooKassa)
Включается через `YOOKASSA_AUTOPAYMENTS_ENABLED=true`. Если включено и пользователь сохранил карту, то за `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` дней до окончания бот пытается списать сумму, равную `RUB_PRICE_*` для длительности из последнего платежа. См. `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` для управления чекбоксом «сохранить карту».
В режиме трафика автопродление принудительно выключается, даже если YooKassa-настройки разрешают его.
### Уведомления
Управляются `SUBSCRIPTION_NOTIFICATIONS_ENABLED`, `SUBSCRIPTION_NOTIFY_ON_EXPIRE`, `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`, `SUBSCRIPTION_NOTIFY_DAYS_BEFORE`. Работают по `end_date` подписки — поэтому в режиме трафика, где `end_date` всегда «2099», они эффективно не отправляются.
## Режим «Пакеты трафика»
Включается заданием переменной `TRAFFIC_PACKAGES` (и/или `STARS_TRAFFIC_PACKAGES`) в `.env`.
### Формат
```env
# Пакеты в основной валюте (RUB), формат "<GB>:<цена>", через запятую
TRAFFIC_PACKAGES=10:199,50:799,200:1999
# Пакеты в Telegram Stars (опционально, если включён STARS_ENABLED)
STARS_TRAFFIC_PACKAGES=10:2500,50:9000
```
Для дробных размеров допускается `0.5:99`, `1.5:299` и т. п. Цены допускают `0` (например, для теста).
Если задана только `STARS_TRAFFIC_PACKAGES`, при выборе пакета бот покажет цену в звёздах и автоматически подменит символ валюты на `⭐`. Если фиатные провайдеры (`YOOKASSA_ENABLED`, `FREEKASSA_ENABLED`, `PLATEGA_ENABLED`, `SEVERPAY_ENABLED`, `CRYPTOPAY_ENABLED`) включены, но `TRAFFIC_PACKAGES` не задана — бот сочтёт это ошибкой конфигурации и не пропустит покупку.
### Что происходит при оплате
1. К текущему лимиту трафика пользователя в панели **прибавляется** купленный объём (`new_limit = current_limit + purchase_bytes`). Это работает и для существующих подписчиков.
2. Стратегия сброса принудительно ставится в `NO_RESET` — иначе докупленные ГБ обнулялись бы по расписанию.
3. `end_date` ставится в `2099-01-01 UTC` (или сохраняется более поздняя, если по какой-то причине уже была). Это нужно, чтобы Remnawave не помечал пользователя как просроченного.
4. Реферальные бонусы и автопродление при таких платежах отключены: даже если в `.env` они настроены, в этом режиме бот их не применяет.
5. Поле `subscription_duration_months` в БД и в CSV-выгрузке платежей в этом режиме интерпретируется как «количество ГБ», а не как месяцы. В UI и админке это учитывается, но имейте в виду при ручных запросах к БД.
### Триал в режиме трафика
Триал работает по той же логике, что и в режиме подписок: задаётся `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY`. То есть пробный период всё ещё ограничен по времени, даже если основная продажа — это пакеты трафика.
## Как переключиться между режимами
1. В `.env` либо заполните `TRAFFIC_PACKAGES` (включится режим трафика), либо очистите её (включится режим подписок).
2. Перезапустите контейнер: `docker compose up -d`.
3. Существующие подписчики не теряют доступ:
- При переключении в режим трафика их `end_date` остаётся прежним до следующей покупки. Покупка пакета установит `end_date` = «2099» и переведёт стратегию в `NO_RESET`.
- При обратном переключении следующий платёж задаст обычный `end_date` от «сейчас» (или продлит существующий).
> ⚠️ Одновременно совмещать продажу подписок и трафика бот **не умеет**. Проверка идёт по одному булевому флагу `traffic_sale_mode`.
## Чеки для самозанятых (nalog.ru)
Для каждого режима используется отдельный шаблон названия в чеке:
| Переменная | Назначение |
| --- | --- |
| `NALOGO_RECEIPT_NAME_SUBSCRIPTION` | Шаблон для подписки. Поддерживает `{months}` |
| `NALOGO_RECEIPT_NAME_TRAFFIC` | Шаблон для пакета трафика. Поддерживает `{gb}` |
Пример: `NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB`.
## Связанные переменные
| Переменная | Описание |
| --- | --- |
| `DEFAULT_CURRENCY_SYMBOL` | Что показывается рядом с фиатной ценой (`RUB`, `USD`, `EUR`, …) |
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты — общий для обоих режимов |
| `STARS_ENABLED` | Без него Stars-цены не предложатся, даже если заданы |
| `USER_HWID_DEVICE_LIMIT` | Лимит устройств — общий для обоих режимов, к тарификации не относится |
## Где это всё в коде
- Чтение и парсинг переменных: [config/settings.py](../config/settings.py) — `subscription_options`, `traffic_packages`, `stars_traffic_packages`, `traffic_sale_mode`.
- Активация платежа: [bot/services/subscription_service.py](../bot/services/subscription_service.py) — `activate_subscription` (ветка по `sale_mode`) и `_activate_traffic_package`.
- Выбор тарифа в боте: [bot/handlers/user/subscription/core.py](../bot/handlers/user/subscription/core.py) и [bot/handlers/user/subscription/payments_subscription.py](../bot/handlers/user/subscription/payments_subscription.py).
- Web App: [bot/app/web/subscription_webapp.py](../bot/app/web/subscription_webapp.py) — поле `traffic_mode` в payload и логика отображения трафика.
+209 -4
View File
@@ -11,6 +11,7 @@
"menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase",
"menu_personal_account_button": "🔑 Personal Account",
"menu_bot_interface_button": "🤖 Bot menu",
"menu_my_subscription_inline": "🔐 My Subscription",
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
"menu_referral_inline": "🎁 Invite friend",
@@ -22,6 +23,7 @@
"menu_terms_button": "📄 Terms of Service",
"menu_info_button": "️ Information",
"bot_interface_menu_title": "Bot interface",
"bot_interface_menu_webapp_hint": "The Personal Account is the easier place to manage the service.",
"info_links_message": "Choose a document:",
"privacy_policy_button": "🔒 Privacy Policy",
"user_agreement_button": "📄 User Agreement",
@@ -57,7 +59,6 @@
"pay_with_stars_button": "🌟 Telegram Stars",
"connect_button": "🔗 Connect",
"cancel_button": "❌ Cancel",
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>",
"no_devices_details_found_message": "📱 <b>My Devices</b>\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.",
@@ -67,7 +68,6 @@
"no_devices_found": "Error loading device list.",
"devices_unlimited_label": "Unlimited",
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
"payment_description_subscription": "Subscription payment for {months} mo.",
"payment_description_traffic": "Traffic package {traffic_gb} GB",
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
@@ -102,11 +102,13 @@
"referral_welcome_bonus_applied": "🎁 You have received {days} bonus day(s) for registering via a referral link!\nYour subscription is active until {end_date}.",
"yes_button": "Yes",
"no_button": "No",
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Telegram link:\n<code>{referral_link}</code>{webapp_link_section}\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
"referral_share_message_button": "📩 Message for friend",
"referral_friend_message": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
"referral_friend_message_with_webapp": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\nTelegram: {referral_link}\nWeb: {webapp_referral_link}",
"referral_webapp_link_line": "\n🌐 Web link:\n<code>{webapp_referral_link}</code>",
"friend_placeholder": "friend",
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
@@ -334,6 +336,9 @@
"log_open_profile_link": "👤 Open profile",
"log_open_referrer_profile_button": "👤 Referrer profile",
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
"log_new_email_user_registration": "📧 <b>New User (email)</b>\n\n🆔 ID: <code>{user_id}</code>\n📧 Email: <code>{email}</code>{referral_text}\n📅 Time: {timestamp}",
"log_account_email_linked": "📧 <b>Email linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
"log_account_telegram_linked": "📨 <b>Telegram linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
@@ -518,5 +523,205 @@
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
"admin_ads_deleted_success": "Campaign deleted.",
"admin_ads_not_found": "Campaign not found.",
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
"free_kassa_order_full": "Order #{order_id} from {date}\n\n",
"email_footer_auto": "Sent automatically by {brand}. Please don't reply.",
"email_login_code_subject": "{code} — your sign-in code",
"email_login_code_preheader": "Your one-time code expires in {minutes} min.",
"email_login_code_heading": "Confirm your sign-in",
"email_login_code_intro": "Use this 6-digit code to finish signing in to your subscription dashboard.",
"email_login_code_expiry_html": "The code is valid for <strong style=\"color:#e6e9ef;\">{minutes} min</strong>.",
"email_login_code_security": "If you didn't request this code, you can ignore this email — your account stays safe.",
"email_login_code_magic_cta": "Sign in with one tap",
"email_login_code_magic_intro": "Don't want to type the code? Sign in with this link.",
"email_login_code_magic_hint": "The link is single-use and expires together with the code.",
"email_login_code_magic_or": "or",
"email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.",
"email_login_code_text_magic": "Or sign in with this single-use link: {url}",
"email_account_merged_subject": "Accounts merged",
"email_account_merged_preheader": "Your accounts were combined into one profile.",
"email_account_merged_heading": "Accounts merged",
"email_account_merged_intro": "We merged your accounts into a single profile so you keep one subscription and one history of payments.",
"email_account_merged_row_kept": "Kept account",
"email_account_merged_row_removed": "Removed account",
"email_account_merged_row_end_date": "New end date",
"email_account_merged_note": "Your subscription link is unchanged. The duplicate panel user was removed automatically.",
"email_account_merged_text": "We merged your accounts into one profile.\n\nKept account: {primary}\nRemoved account: {removed}\nPaid periods were combined. New subscription end date: {end_date}.\nYour subscription link stayed the same, and the later account was removed from Remnawave automatically.",
"email_payment_success_subject": "Payment received",
"email_payment_success_preheader": "Thanks — your subscription has been activated.",
"email_payment_success_heading": "Payment received",
"email_payment_success_intro_subscription": "Thanks! Your subscription has been extended by {months} month(s).",
"email_payment_success_intro_traffic": "Thanks! We added {traffic_gb} GB of traffic to your account.",
"email_payment_success_row_period": "Period",
"email_payment_success_row_traffic": "Traffic added",
"email_payment_success_row_amount": "Amount",
"email_payment_success_row_end_date": "Active until",
"email_payment_success_row_method": "Method",
"email_payment_success_period_value": "{months} month(s)",
"email_payment_success_traffic_value": "{traffic_gb} GB",
"email_payment_success_cta": "Open dashboard",
"email_payment_success_footer_note": "We'll remind you a few days before the subscription ends.",
"email_payment_success_text_subscription": "Payment received: {amount}.\nSubscription extended by {months} month(s).\nActive until: {end_date}.",
"email_payment_success_text_traffic": "Payment received: {amount}.\nTraffic added: {traffic_gb} GB.\nActive until: {end_date}.",
"email_payment_success_text_dashboard": "Dashboard: {url}",
"email_subscription_expiring_subject_today": "Your subscription ends today",
"email_subscription_expiring_subject_tomorrow": "1 day left on your subscription",
"email_subscription_expiring_subject_days": "{days} days left on your subscription",
"email_subscription_expiring_heading_today": "Subscription ends today",
"email_subscription_expiring_heading_tomorrow": "1 day left on your subscription",
"email_subscription_expiring_heading_days": "{days} days left on your subscription",
"email_subscription_expiring_preheader_today": "Renew today to avoid disconnection.",
"email_subscription_expiring_preheader_tomorrow": "Your subscription expires tomorrow.",
"email_subscription_expiring_preheader_days": "Your subscription expires in {days} days.",
"email_subscription_expiring_intro_today": "Your subscription is about to expire. Renew now to keep your connection active without interruption.",
"email_subscription_expiring_intro_tomorrow": "Your subscription expires tomorrow. Renew now to avoid losing access.",
"email_subscription_expiring_intro_days": "A heads-up so you can renew in advance and avoid any interruption.",
"email_subscription_expiring_row_days_left": "Days left",
"email_subscription_expiring_row_end_date": "Active until",
"email_subscription_expiring_cta": "Renew subscription",
"email_subscription_expiring_note": "If you've already renewed or use auto-renewal, you can ignore this email.",
"email_subscription_expiring_text": "{heading}.\nActive until: {end_date}.",
"email_subscription_expiring_text_renew": "Renew: {url}",
"wa_loading": "Loading...",
"wa_back": "Back",
"wa_close": "Close",
"wa_auth_checking_login": "Checking sign-in...",
"wa_auth_login_confirm_failed": "Could not confirm sign-in",
"wa_auth_checking_telegram": "Checking Telegram...",
"wa_auth_access_denied": "Access denied",
"wa_auth_telegram_not_confirmed": "Telegram sign-in not confirmed",
"wa_auth_telegram_unavailable": "Telegram sign-in is currently unavailable",
"wa_auth_telegram_not_configured": "Telegram sign-in is not configured",
"wa_auth_telegram_cancelled": "Telegram sign-in was cancelled",
"wa_auth_invalid_email": "Enter a valid email",
"wa_auth_sending_code": "Sending code...",
"wa_auth_send_code_failed": "Failed to send code",
"wa_auth_enter_code_6digits": "Enter the 6-digit code from the email",
"wa_auth_checking_code": "Checking code...",
"wa_auth_invalid_code": "Invalid code",
"wa_auth_code_expired": "Code expired",
"wa_auth_resend_wait": "You can resend in {seconds} sec.",
"wa_email_verification_title": "Email confirmation",
"wa_email_sent_to": "We sent a code to {email}",
"wa_email_code_aria": "Verification code",
"wa_email_placeholder": "Email",
"wa_confirm": "Confirm",
"wa_resend_code": "Resend code",
"wa_send_code_email": "Send code to email",
"wa_or": "or",
"wa_login_telegram_button": "Sign in with Telegram",
"wa_auth_legal_intro": "By creating an account, you agree to the",
"wa_auth_legal_privacy": "privacy policy",
"wa_auth_legal_and": "and",
"wa_auth_legal_agreement": "user agreement",
"wa_home_subscription_active": "Subscription active",
"wa_home_access_active": "Access active",
"wa_sub_term_forever": "Forever",
"wa_sub_term_value_unit": "{value} {unit}",
"wa_sub_term_day_one": "day",
"wa_sub_term_day_few": "days",
"wa_sub_term_day_many": "days",
"wa_sub_term_month_one": "month",
"wa_sub_term_month_few": "months",
"wa_sub_term_month_many": "months",
"wa_sub_term_year_one": "year",
"wa_sub_term_year_few": "years",
"wa_sub_term_year_many": "years",
"wa_home_subscription_inactive": "Subscription inactive",
"wa_until_date": "until {date}",
"wa_home_traffic_used": "Traffic used",
"wa_renew": "Renew",
"wa_pay_subscription": "Pay subscription",
"wa_subscription_title": "Subscription",
"wa_subscription_choose_period": "Choose subscription period",
"wa_per_month_short": "/mo",
"wa_traffic_packages_title": "Traffic",
"wa_traffic_packages_choose": "Choose traffic package",
"wa_per_gb_short": "/GB",
"wa_buy_traffic": "Buy traffic",
"wa_activate_trial": "Activate trial",
"wa_trial_title": "Trial period",
"wa_trial_details": "{days} days, {traffic}",
"wa_trial_activated": "Trial activated",
"wa_trial_activation_failed": "Failed to activate trial",
"wa_install_and_configure": "Install and configure",
"wa_payment_methods_not_configured": "Payment methods are not configured yet",
"wa_pay": "Pay",
"wa_referral_link_title": "Your referral link",
"wa_link_unavailable": "Link is not available yet",
"wa_link_copied": "Link copied",
"wa_copy": "Copy",
"wa_referral_bonus_overview_title": "Bonus system",
"wa_referral_bonus_once_note": "Bonus is granted only once per invited user after their first payment.",
"wa_referral_bonus_registration_title": "For registration via referral link",
"wa_referral_bonus_paid_intro": "If a friend pays for a subscription:",
"wa_referral_bonus_you_days": "You: +{days} days",
"wa_referral_bonus_friend_days": "Friend: +{days} days",
"wa_referral_bonus_not_configured": "Referral bonuses are not configured",
"wa_promo_enter": "Enter promo code",
"wa_promo_activated_until": "Promo code activated. Subscription until {date}",
"wa_promo_activated": "Promo code activated",
"wa_promo_activation_failed": "Failed to activate promo code",
"wa_activate": "Activate",
"wa_settings_avatar_alt": "User avatar",
"wa_settings_language": "Language",
"wa_settings_link_telegram": "Telegram linked",
"wa_settings_not_linked": "Not linked",
"wa_settings_link_email": "Email linked",
"wa_settings_email_not_linked": "Not linked",
"wa_settings_linked": "Linked",
"wa_settings_language_update_failed": "Failed to update language",
"wa_settings_user_agreement": "User agreement",
"wa_settings_privacy_policy": "Privacy policy",
"wa_logout": "Log out",
"wa_end_session": "End session",
"wa_navigation": "Navigation",
"wa_nav_home": "Home",
"wa_nav_settings": "Settings",
"wa_nav_devices": "Devices",
"wa_devices_title": "My devices",
"wa_devices_count": "{current} of {max}",
"wa_devices_unlimited": "Unlimited",
"wa_devices_refresh": "Refresh devices",
"wa_devices_loading": "Loading devices...",
"wa_devices_load_failed": "Failed to load devices",
"wa_devices_empty": "No devices yet",
"wa_devices_empty_hint": "You can connect up to {max} devices from your subscription app.",
"wa_device_fallback_name": "Device {index}",
"wa_devices_platform_unknown": "Platform unknown",
"wa_devices_connected_at": "Connected",
"wa_devices_disconnect": "Disconnect device",
"wa_devices_disconnect_title": "Disconnect device",
"wa_devices_disconnect_desc": "{device} will be removed from your device list. It may connect again next time you use the subscription on that device.",
"wa_devices_disconnect_confirm": "Disconnect",
"wa_device_disconnected": "Device disconnected",
"wa_device_disconnect_failed": "Failed to disconnect device",
"wa_cancel": "Cancel",
"wa_payment_created": "Payment created",
"wa_payment_create_failed": "Failed to create payment",
"wa_connect_link_unavailable": "Connection link is not available yet",
"wa_copied": "Copied",
"wa_unavailable": "Not available yet",
"wa_method_other_title": "Other methods",
"wa_unlimited_traffic": "Unlimited traffic",
"wa_traffic_of": "{used} of {limit}",
"wa_language_default": "English",
"wa_telegram_not_linked": "Telegram not linked",
"wa_tg_id_not_linked": "TG ID not linked",
"wa_nav_bonuses": "Bonuses",
"wa_method_platega_sbp_card": "SBP/Card",
"wa_method_platega_crypto": "Crypto",
"wa_settings_link_telegram_action": "Link Telegram",
"wa_settings_telegram_linked_title": "Telegram linked",
"wa_settings_link_email_action": "Link email",
"wa_settings_email_linked_title": "Email linked",
"wa_activate_promo_title": "Activate promo code",
"wa_link_email_modal_title": "Link email",
"wa_traffic_reset_none": "No reset",
"wa_traffic_reset_monthly": "Monthly reset",
"wa_traffic_reset_weekly": "Weekly reset",
"wa_traffic_reset_daily": "Daily reset",
"wa_traffic_reset_yearly": "Yearly reset",
"wa_traffic_reset_policy": "Traffic reset policy",
"wa_plan_one_year": "1 year",
"wa_link_email_modal_desc": "Enter your email and get a verification code"
}
+209 -4
View File
@@ -11,6 +11,7 @@
"menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить",
"menu_personal_account_button": "🔑 Личный кабинет",
"menu_bot_interface_button": "🤖 Бот-меню",
"menu_my_subscription_inline": "🔐 Моя подписка",
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
"menu_referral_inline": "🎁 Пригласить друга",
@@ -22,6 +23,7 @@
"menu_terms_button": "📄 Условия сервиса",
"menu_info_button": "ℹ️ Информация",
"bot_interface_menu_title": "Интерфейс в боте",
"bot_interface_menu_webapp_hint": "В личном кабинете взаимодействовать с сервисом будет удобнее.",
"info_links_message": "Выберите документ:",
"privacy_policy_button": "🔒 Политика конфиденциальности",
"user_agreement_button": "📄 Пользовательское соглашение",
@@ -56,7 +58,6 @@
"pay_with_cryptopay_button": "💎 CryptoBot",
"pay_with_stars_button": "🌟 Звезды Telegram",
"connect_button": "🔗 Подключиться",
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
@@ -66,7 +67,6 @@
"no_devices_found": "Ошибка загрузки списка устройств.",
"devices_unlimited_label": "Без ограничений",
"my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
"cancel_button": "❌ Отмена",
"payment_description_subscription": "Оплата подписки на {months} мес.",
"payment_description_traffic": "Пакет трафика {traffic_gb} ГБ",
@@ -102,11 +102,13 @@
"referral_welcome_bonus_applied": "🎁 Вам начислено {days} бонусных дн. за регистрацию по реферальной ссылке!\nПодписка активна до {end_date}.",
"yes_button": "Да",
"no_button": "Нет",
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Telegram ссылка:\n<code>{referral_link}</code>{webapp_link_section}\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
"referral_share_message_button": "📩 Сообщение для друга",
"referral_friend_message": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
"referral_friend_message_with_webapp": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\nTelegram: {referral_link}\nWeb: {webapp_referral_link}",
"referral_webapp_link_line": "\n🌐 Web ссылка:\n<code>{webapp_referral_link}</code>",
"friend_placeholder": "друг",
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
@@ -334,6 +336,9 @@
"log_open_profile_link": "👤 Открыть профиль",
"log_open_referrer_profile_button": "👤 Профиль пригласившего",
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
"log_new_email_user_registration": "📧 <b>Новый пользователь (email)</b>\n\n🆔 ID: <code>{user_id}</code>\n📧 Email: <code>{email}</code>{referral_text}\n📅 Время: {timestamp}",
"log_account_email_linked": "📧 <b>Привязана почта</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
"log_account_telegram_linked": "📨 <b>Привязан Telegram</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
@@ -518,5 +523,205 @@
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
"admin_ads_deleted_success": "Кампания удалена.",
"admin_ads_not_found": "Кампания не найдена.",
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n",
"email_footer_auto": "Это автоматическое письмо от {brand}. Отвечать на него не нужно.",
"email_login_code_subject": "{code} — код для входа",
"email_login_code_preheader": "Одноразовый код действует {minutes} мин.",
"email_login_code_heading": "Подтвердите вход",
"email_login_code_intro": "Введите этот 6-значный код, чтобы завершить вход в личный кабинет подписки.",
"email_login_code_expiry_html": "Код действует <strong style=\"color:#e6e9ef;\">{minutes} мин</strong>.",
"email_login_code_security": "Если вы не запрашивали код, просто проигнорируйте это письмо — ваш аккаунт в безопасности.",
"email_login_code_magic_cta": "Войти одним нажатием",
"email_login_code_magic_intro": "Не хотите вводить код? Войдите по ссылке.",
"email_login_code_magic_hint": "Ссылка одноразовая и действует столько же, сколько код.",
"email_login_code_magic_or": "или",
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
"email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}",
"email_account_merged_subject": "Аккаунты объединены",
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
"email_account_merged_heading": "Аккаунты объединены",
"email_account_merged_intro": "Мы объединили ваши аккаунты в один профиль — теперь у вас одна подписка и общая история оплат.",
"email_account_merged_row_kept": "Оставлен аккаунт",
"email_account_merged_row_removed": "Удалён аккаунт",
"email_account_merged_row_end_date": "Новая дата окончания",
"email_account_merged_note": "Ссылка на подписку осталась прежней. Дублирующий пользователь панели был удалён автоматически.",
"email_account_merged_text": "Мы объединили ваши аккаунты в один профиль.\n\nОставлен аккаунт: {primary}\nУдалён аккаунт: {removed}\nОплаченные периоды сложились. Новая дата окончания подписки: {end_date}.\nСсылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически.",
"email_payment_success_subject": "Платёж получен",
"email_payment_success_preheader": "Спасибо — подписка активирована.",
"email_payment_success_heading": "Платёж получен",
"email_payment_success_intro_subscription": "Спасибо! Ваша подписка продлена на {months} мес.",
"email_payment_success_intro_traffic": "Спасибо! Мы зачислили {traffic_gb} ГБ трафика на ваш аккаунт.",
"email_payment_success_row_period": "Период",
"email_payment_success_row_traffic": "Трафик",
"email_payment_success_row_amount": "Сумма",
"email_payment_success_row_end_date": "Действует до",
"email_payment_success_row_method": "Способ оплаты",
"email_payment_success_period_value": "{months} мес.",
"email_payment_success_traffic_value": "{traffic_gb} ГБ",
"email_payment_success_cta": "Открыть кабинет",
"email_payment_success_footer_note": "За несколько дней до окончания мы напомним о продлении.",
"email_payment_success_text_subscription": "Платёж получен: {amount}.\nПодписка продлена на {months} мес.\nДействует до: {end_date}.",
"email_payment_success_text_traffic": "Платёж получен: {amount}.\nЗачислено трафика: {traffic_gb} ГБ.\nДействует до: {end_date}.",
"email_payment_success_text_dashboard": "Кабинет: {url}",
"email_subscription_expiring_subject_today": "Подписка заканчивается сегодня",
"email_subscription_expiring_subject_tomorrow": "Подписка заканчивается завтра",
"email_subscription_expiring_subject_days": "До конца подписки осталось {days} дн.",
"email_subscription_expiring_heading_today": "Подписка заканчивается сегодня",
"email_subscription_expiring_heading_tomorrow": "Подписка заканчивается завтра",
"email_subscription_expiring_heading_days": "До конца подписки {days} дн.",
"email_subscription_expiring_preheader_today": "Продлите сегодня, чтобы не потерять доступ.",
"email_subscription_expiring_preheader_tomorrow": "Подписка истекает завтра.",
"email_subscription_expiring_preheader_days": "Подписка заканчивается через {days} дн.",
"email_subscription_expiring_intro_today": "Ваша подписка вот-вот истечёт. Продлите её, чтобы соединение не прерывалось.",
"email_subscription_expiring_intro_tomorrow": "Ваша подписка истекает завтра. Продлите её, чтобы не потерять доступ.",
"email_subscription_expiring_intro_days": "Напоминаем заранее, чтобы вы успели продлить подписку без перерыва в работе.",
"email_subscription_expiring_row_days_left": "Осталось дней",
"email_subscription_expiring_row_end_date": "Действует до",
"email_subscription_expiring_cta": "Продлить подписку",
"email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.",
"email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.",
"email_subscription_expiring_text_renew": "Продлить: {url}",
"wa_loading": "Загрузка...",
"wa_back": "Назад",
"wa_close": "Закрыть",
"wa_auth_checking_login": "Проверяем вход...",
"wa_auth_login_confirm_failed": "Не удалось подтвердить вход",
"wa_auth_checking_telegram": "Проверяем Telegram...",
"wa_auth_access_denied": "Доступ запрещен",
"wa_auth_telegram_not_confirmed": "Telegram-вход не подтвержден",
"wa_auth_telegram_unavailable": "Telegram-вход сейчас недоступен",
"wa_auth_telegram_not_configured": "Telegram-вход не настроен",
"wa_auth_telegram_cancelled": "Вход через Telegram отменён",
"wa_auth_invalid_email": "Введите корректный email",
"wa_auth_sending_code": "Отправляем код...",
"wa_auth_send_code_failed": "Не удалось отправить код",
"wa_auth_enter_code_6digits": "Введите 6 цифр из письма",
"wa_auth_checking_code": "Проверяем код...",
"wa_auth_invalid_code": "Неверный код",
"wa_auth_code_expired": "Код устарел",
"wa_auth_resend_wait": "Повторная отправка через {seconds} сек.",
"wa_email_verification_title": "Подтверждение по email",
"wa_email_sent_to": "Мы отправили код на {email}",
"wa_email_code_aria": "Код подтверждения",
"wa_email_placeholder": "Email",
"wa_confirm": "Подтвердить",
"wa_resend_code": "Отправить код повторно",
"wa_send_code_email": "Отправить код на почту",
"wa_or": "или",
"wa_login_telegram_button": "Войти через телеграм",
"wa_auth_legal_intro": "Создавая аккаунт, вы соглашаетесь с",
"wa_auth_legal_privacy": "политикой конфиденциальности",
"wa_auth_legal_and": "и",
"wa_auth_legal_agreement": "пользовательским соглашением",
"wa_home_subscription_active": "Подписка активна",
"wa_home_access_active": "Доступ активен",
"wa_sub_term_forever": "Навсегда",
"wa_sub_term_value_unit": "{value} {unit}",
"wa_sub_term_day_one": "день",
"wa_sub_term_day_few": "дня",
"wa_sub_term_day_many": "дней",
"wa_sub_term_month_one": "месяц",
"wa_sub_term_month_few": "месяца",
"wa_sub_term_month_many": "месяцев",
"wa_sub_term_year_one": "год",
"wa_sub_term_year_few": "года",
"wa_sub_term_year_many": "лет",
"wa_home_subscription_inactive": "Подписка не активна",
"wa_until_date": "до {date}",
"wa_home_traffic_used": "Использовано трафика",
"wa_renew": "Продлить",
"wa_pay_subscription": "Оплатить подписку",
"wa_subscription_title": "Подписка",
"wa_subscription_choose_period": "Выберите срок подписки",
"wa_per_month_short": "/мес",
"wa_traffic_packages_title": "Трафик",
"wa_traffic_packages_choose": "Выберите пакет трафика",
"wa_per_gb_short": "/ГБ",
"wa_buy_traffic": "Купить трафик",
"wa_activate_trial": "Активировать триал",
"wa_trial_title": "Пробный период",
"wa_trial_details": "{days} дн., {traffic}",
"wa_trial_activated": "Пробный период активирован",
"wa_trial_activation_failed": "Не удалось активировать пробный период",
"wa_install_and_configure": "Установить и настроить",
"wa_payment_methods_not_configured": "Способы оплаты пока не настроены",
"wa_pay": "Оплатить",
"wa_referral_link_title": "Ваша реферальная ссылка",
"wa_link_unavailable": "Ссылка пока недоступна",
"wa_link_copied": "Ссылка скопирована",
"wa_copy": "Копировать",
"wa_referral_bonus_overview_title": "Система бонусов",
"wa_referral_bonus_once_note": "Бонус за приглашённого начисляется только один раз, после его первой оплаты.",
"wa_referral_bonus_registration_title": "За регистрацию по реферальной ссылке",
"wa_referral_bonus_paid_intro": "Если друг оплатит подписку:",
"wa_referral_bonus_you_days": "Вам: +{days} дней",
"wa_referral_bonus_friend_days": "Другу: +{days} дней",
"wa_referral_bonus_not_configured": "Реферальные бонусы не настроены",
"wa_promo_enter": "Введите промокод",
"wa_promo_activated_until": "Промокод активирован. Подписка до {date}",
"wa_promo_activated": "Промокод активирован",
"wa_promo_activation_failed": "Не удалось активировать промокод",
"wa_activate": "Активировать",
"wa_settings_avatar_alt": "Аватар пользователя",
"wa_settings_language": "Выбор языка",
"wa_settings_link_telegram": "Привязка Telegram",
"wa_settings_not_linked": "Не привязан",
"wa_settings_link_email": "Привязка почты",
"wa_settings_email_not_linked": "Не привязана",
"wa_settings_linked": "Привязано",
"wa_settings_language_update_failed": "Не удалось обновить язык",
"wa_settings_user_agreement": "Пользовательское соглашение",
"wa_settings_privacy_policy": "Политика конфиденциальности",
"wa_logout": "Выйти",
"wa_end_session": "Завершить сессию",
"wa_navigation": "Навигация",
"wa_nav_home": "Главная",
"wa_nav_settings": "Настройки",
"wa_nav_devices": "Устройства",
"wa_devices_title": "Мои устройства",
"wa_devices_count": "{current} из {max}",
"wa_devices_unlimited": "Без ограничений",
"wa_devices_refresh": "Обновить устройства",
"wa_devices_loading": "Загружаем устройства...",
"wa_devices_load_failed": "Не удалось загрузить устройства",
"wa_devices_empty": "Устройств пока нет",
"wa_devices_empty_hint": "Можно подключить до {max} устройств через приложение с подпиской.",
"wa_device_fallback_name": "Устройство {index}",
"wa_devices_platform_unknown": "Платформа неизвестна",
"wa_devices_connected_at": "Подключено",
"wa_devices_disconnect": "Отключить устройство",
"wa_devices_disconnect_title": "Отключить устройство",
"wa_devices_disconnect_desc": "{device} будет удалено из списка устройств. Оно может подключиться снова при следующем использовании подписки на этом устройстве.",
"wa_devices_disconnect_confirm": "Отключить",
"wa_device_disconnected": "Устройство отключено",
"wa_device_disconnect_failed": "Не удалось отключить устройство",
"wa_cancel": "Отмена",
"wa_payment_created": "Платеж создан",
"wa_payment_create_failed": "Не удалось создать платеж",
"wa_connect_link_unavailable": "Ссылка для подключения пока недоступна",
"wa_copied": "Скопировано",
"wa_unavailable": "Пока недоступно",
"wa_method_other_title": "Другие способы",
"wa_unlimited_traffic": "Безлимитный трафик",
"wa_traffic_of": "{used} из {limit}",
"wa_language_default": "Русский",
"wa_telegram_not_linked": "Telegram не привязан",
"wa_tg_id_not_linked": "TG ID не привязан",
"wa_nav_bonuses": "Бонусы",
"wa_method_platega_sbp_card": "СБП/Карта",
"wa_method_platega_crypto": "Крипта",
"wa_settings_link_telegram_action": "Привязать телеграм",
"wa_settings_telegram_linked_title": "Телеграм привязан",
"wa_settings_link_email_action": "Привязать почту",
"wa_settings_email_linked_title": "Почта привязана",
"wa_activate_promo_title": "Активировать промокод",
"wa_link_email_modal_title": "Привязать почту",
"wa_traffic_reset_none": "Без сброса",
"wa_traffic_reset_monthly": "Сброс ежемесячно",
"wa_traffic_reset_weekly": "Сброс еженедельно",
"wa_traffic_reset_daily": "Сброс ежедневно",
"wa_traffic_reset_yearly": "Сброс ежегодно",
"wa_traffic_reset_policy": "Стратегия сброса трафика",
"wa_plan_one_year": "1 год",
"wa_link_email_modal_desc": "Введите email и получите код подтверждения"
}
+993 -1
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -1,13 +1,22 @@
{
"private": true,
"scripts": {
"build:webapp:css": "tailwindcss -i ./bot/app/web/templates/subscription_webapp.tailwind.css -o ./bot/app/web/templates/subscription_webapp.css --minify",
"build:webapp:svelte": "vite build --config ./bot/app/web/frontend/vite.config.mjs",
"build:webapp:css": "npm run build:webapp:svelte",
"build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs",
"build:webapp": "npm run build:webapp:css && npm run build:webapp:js"
"build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/cli": "4.2.4",
"bits-ui": "^2.18.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"esbuild": "^0.28.0",
"tailwindcss": "4.2.4"
"lucide-svelte": "^1.0.1",
"svelte": "^5.55.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "4.2.4",
"vite": "^8.0.10"
}
}
+1
View File
@@ -9,3 +9,4 @@ email-validator==2.3.0
sqlalchemy[asyncio]==2.0.45
asyncpg==0.31.0
aiocryptopay==0.4.8
PyJWT[crypto]==2.10.1
-57
View File
@@ -1,57 +0,0 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from urllib.request import Request, urlopen
SOURCE_URL = "https://telegram.org/js/telegram-web-app.js"
TARGET_PATH = (
Path(__file__).resolve().parents[1]
/ "bot"
/ "app"
/ "web"
/ "templates"
/ "telegram-web-app.js"
)
def _download(source_url: str) -> bytes:
request = Request(
source_url,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
)
with urlopen(request, timeout=30) as response:
return response.read()
def main() -> int:
parser = argparse.ArgumentParser(
description="Download the latest Telegram Web App SDK into the local templates directory."
)
parser.add_argument(
"--source-url",
default=SOURCE_URL,
help="Telegram Web App SDK URL to download from.",
)
parser.add_argument(
"--target",
type=Path,
default=TARGET_PATH,
help="Output path for the vendored SDK.",
)
args = parser.parse_args()
data = _download(args.source_url)
args.target.parent.mkdir(parents=True, exist_ok=True)
args.target.write_bytes(data)
print(f"Wrote {len(data)} bytes to {args.target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-70
View File
@@ -1,70 +0,0 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from urllib.request import Request, urlopen
SOURCE_URL = "https://telegram.org/js/telegram-widget.js?23"
TARGET_PATH = (
Path(__file__).resolve().parents[1]
/ "bot"
/ "app"
/ "web"
/ "templates"
/ "telegram-widget.js"
)
_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n"""
_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n"""
def _download(source_url: str) -> bytes:
request = Request(
source_url,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
)
with urlopen(request, timeout=30) as response:
return response.read()
def _normalize_widget_sdk(data: bytes) -> bytes:
# Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin.
text = data.decode("utf-8")
normalized = text.replace(
_UNPATCHED_WIDGET_ORIGIN_SNIPPET,
_PATCHED_WIDGET_ORIGIN_SNIPPET,
1,
)
return normalized.encode("utf-8")
def main() -> int:
parser = argparse.ArgumentParser(
description="Download the latest Telegram Login Widget SDK into the local templates directory."
)
parser.add_argument(
"--source-url",
default=SOURCE_URL,
help="Telegram Login Widget SDK URL to download from.",
)
parser.add_argument(
"--target",
type=Path,
default=TARGET_PATH,
help="Output path for the vendored SDK.",
)
args = parser.parse_args()
data = _normalize_widget_sdk(_download(args.source_url))
args.target.parent.mkdir(parents=True, exist_ok=True)
args.target.write_bytes(data)
print(f"Wrote {len(data)} bytes to {args.target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+28 -1
View File
@@ -7,7 +7,11 @@ from unittest.mock import AsyncMock
from aiohttp import web
from bot.app.web import subscription_webapp
from bot.app.web.webapp_auth import create_webapp_session_token
from bot.app.web.webapp_auth import (
create_telegram_oauth_nonce,
create_webapp_session_token,
verify_telegram_oauth_nonce,
)
from bot.services.crypto_pay_service import CryptoPayService
from bot.handlers.user.payment import yookassa_webhook_route
from bot.services.freekassa_service import FreeKassaService
@@ -227,6 +231,29 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status, 400)
self.assertIn("description_too_long", response.text)
def test_telegram_oauth_nonce_round_trips(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
WEBAPP_SESSION_TTL_SECONDS=3600,
)
nonce = create_telegram_oauth_nonce(settings, ttl_seconds=60)
self.assertTrue(verify_telegram_oauth_nonce(settings, nonce))
self.assertFalse(verify_telegram_oauth_nonce(settings, nonce + "tampered"))
def test_telegram_oauth_client_id_defaults_to_bot_id(self):
settings = SimpleNamespace(
BOT_TOKEN="123456789:secret",
TELEGRAM_OAUTH_CLIENT_ID=None,
TELEGRAM_OAUTH_REQUEST_ACCESS="write, phone, unknown, write",
)
self.assertEqual(subscription_webapp._resolve_telegram_oauth_client_id(settings), 123456789)
self.assertEqual(
subscription_webapp._resolve_telegram_oauth_request_access(settings),
["write", "phone"],
)
def asyncio_run(coro):
import asyncio
+11
View File
@@ -26,3 +26,14 @@ class SettingsTests(unittest.TestCase):
self.assertTrue(settings.WEBAPP_SESSION_SECRET)
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400)
def test_trial_traffic_strategy_is_available(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
TRIAL_TRAFFIC_STRATEGY="WEEK",
)
self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK")
+129
View File
@@ -0,0 +1,129 @@
import json
import unittest
from pathlib import Path
from types import SimpleNamespace
from bot.app.web import subscription_webapp
from bot.handlers.user import referral
from bot.keyboards.inline.user_keyboards import (
get_bot_interface_inline_keyboard,
get_information_links_keyboard,
get_language_selection_keyboard,
get_main_menu_inline_keyboard,
get_referral_link_keyboard,
get_subscription_options_keyboard,
)
class JsonI18nStub:
def __init__(self):
self.translations = json.loads(
Path("locales/en.json").read_text(encoding="utf-8")
)
def gettext(self, lang, key, **kwargs):
text = self.translations[key]
return text.format(**kwargs) if kwargs else text
class UserBotMenuTests(unittest.TestCase):
def setUp(self):
self.i18n = JsonI18nStub()
self.settings = SimpleNamespace(
SUBSCRIPTION_MINI_APP_URL="https://app.example.com/",
SUPPORT_LINK="https://t.me/support",
PRIVACY_POLICY_URL="https://example.com/privacy",
USER_AGREEMENT_URL="https://example.com/agreement",
TERMS_OF_SERVICE_URL="",
TRIAL_ENABLED=True,
SERVER_STATUS_URL="",
)
def _callback_data(self, markup):
return [
button.callback_data
for row in markup.inline_keyboard
for button in row
if button.callback_data
]
def test_main_menu_exposes_bot_menu_and_information(self):
markup = get_main_menu_inline_keyboard("en", self.i18n, self.settings)
callbacks = self._callback_data(markup)
self.assertIn("main_action:bot_interface", callbacks)
self.assertIn("main_action:info", callbacks)
def test_bot_interface_buttons_return_to_bot_interface(self):
markup = get_bot_interface_inline_keyboard("en", self.i18n, self.settings)
callbacks = self._callback_data(markup)
self.assertIn("main_action:bot_subscribe", callbacks)
self.assertIn("main_action:bot_my_subscription", callbacks)
self.assertIn("main_action:bot_referral", callbacks)
self.assertIn("main_action:bot_info", callbacks)
self.assertIn("main_action:back_to_main", callbacks)
def test_nested_bot_menu_keyboards_can_target_bot_interface_back(self):
subscription_markup = get_subscription_options_keyboard(
{1: 100},
"RUB",
"en",
self.i18n,
back_callback="main_action:bot_interface",
)
referral_markup = get_referral_link_keyboard(
"en",
self.i18n,
back_callback="main_action:bot_interface",
)
info_markup = get_information_links_keyboard(
"en",
self.i18n,
"https://example.com/privacy",
"https://example.com/agreement",
back_callback="main_action:bot_interface",
)
language_markup = get_language_selection_keyboard(
self.i18n,
"en",
back_callback="main_action:bot_interface",
)
self.assertIn("main_action:bot_interface", self._callback_data(subscription_markup))
self.assertIn("main_action:bot_interface", self._callback_data(referral_markup))
self.assertIn("main_action:bot_interface", self._callback_data(info_markup))
self.assertIn("set_lang_ru:bot", self._callback_data(language_markup))
def test_webapp_referral_link_uses_ref_query_and_is_normalized(self):
link = referral._build_webapp_referral_link(
"https://app.example.com/invite?utm=channel",
"AbC123xYz",
)
self.assertEqual(
link,
"https://app.example.com/invite?utm=channel&ref=uAbC123xYz",
)
self.assertEqual(subscription_webapp._normalize_referral_param("uAbC123xYz"), "ABC123XYZ")
self.assertEqual(subscription_webapp._normalize_referral_param("ref_uAbC123xYz"), "ABC123XYZ")
def test_referral_text_places_web_link_after_telegram_link(self):
text = self.i18n.gettext(
"en",
"referral_program_info_new",
referral_link="https://t.me/bot?start=ref_uABC123XYZ",
webapp_link_section=self.i18n.gettext(
"en",
"referral_webapp_link_line",
webapp_referral_link="https://app.example.com/?ref=uABC123XYZ",
),
bonus_details="bonus",
invited_count=1,
purchased_count=0,
)
self.assertLess(text.index("Telegram link:"), text.index("Web link:"))
self.assertLess(text.index("Web link:"), text.index("Invitation bonuses:"))
+18
View File
@@ -6,9 +6,27 @@ from types import SimpleNamespace
from unittest.mock import patch
from bot.app.web import subscription_webapp
from config.settings import Settings
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
def test_serialize_plans_uses_traffic_packages_in_traffic_mode(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
TRAFFIC_PACKAGES="10:199,50:799",
STARS_TRAFFIC_PACKAGES="50:2500",
)
plans = subscription_webapp._serialize_plans(settings, "en")
self.assertEqual([plan["traffic_gb"] for plan in plans], [10.0, 50.0])
self.assertEqual(plans[0]["sale_mode"], "traffic")
self.assertEqual(plans[0]["price"], 199.0)
self.assertEqual(plans[1]["stars_price"], 2500)
def test_resolve_webapp_js_asset_name_prefers_latest_minified_build(self):
with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir)