8 Commits

Author SHA1 Message Date
tgvpn 23f5e782f8 Improve Telegram keyboards and fix admin user menu navigation
Unified inline menus, user callbacks for all users, Home button to exit admin panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 01:40:25 +03:00
tgvpn cbb2133991 Add /config trial VPN generation for users (1 day default)
Users get Remnawave subscription via /config or inline button; TRIAL_USER_DAYS and panel lookup by Telegram ID.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 01:29:55 +03:00
tgvpn 30866bb244 Add interactive install.sh for server deployment 2026-05-21 01:22:54 +03:00
tgvpn fd22714c9b Expand README with PostgreSQL documentation and updated setup guide 2026-05-21 01:16:28 +03:00
tgvpn 5e3229e998 Add PostgreSQL, user/squad management, remove private domains from docs 2026-05-21 01:13:23 +03:00
tgvpn d0dc8d5822 Fix test domain examples to j5.evilfox.win 2026-05-21 01:04:43 +03:00
tgvpn 65495ee2bf Release v0.20.0 2026-05-21 01:02:12 +03:00
tgvpn 7d63603150 Align Remnawave config with official docs and fix admin health check 2026-05-21 00:59:55 +03:00
21 changed files with 2389 additions and 225 deletions
+23 -8
View File
@@ -7,14 +7,29 @@ BOT_DEBUG=false
# Telegram user ID администратора (узнать: @userinfobot или @getidsbot) # Telegram user ID администратора (узнать: @userinfobot или @getidsbot)
TELEGRAM_ADMIN_ID=123456789 TELEGRAM_ADMIN_ID=123456789
# Remnawave — панель 1 (https://docs.rw/) # --- Remnawave (официальные имена: https://docs.rw/docs/install/subscription-page/bundled) ---
REMNAWAVE_PANEL_NAME=Панель 1 REMNAWAVE_PANEL_NAME=Панель 1
# URL панели: https://panel.example.com или http://remnawave:3000 (внутри Docker-сети)
REMNAWAVE_PANEL_URL=https://panel.example.com REMNAWAVE_PANEL_URL=https://panel.example.com
# Settings → API Tokens в панели Remnawave # API-токен: Remnawave Settings → API Tokens (Authorization: Bearer)
REMNAWAVE_API_TOKEN=your_api_token_here REMNAWAVE_API_TOKEN=API_TOKEN_FROM_REMNAWAVE
# Опционально, если перед панелью стоит Caddy с X-Api-Key # Если используется Caddy with security — X-Api-Key к панели
REMNAWAVE_CADDY_TOKEN= CADDY_AUTH_API_TOKEN=
# Публичная страница подписки (Subscription Page), для проверки доступности # Опционально: Subscription Page (например https://sub.example.com)
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com REMNAWAVE_SUBSCRIPTION_URL=
# Docker Compose читает этот файл как .env (скопируйте: cp .env.example .env) # PostgreSQL (должен совпадать с POSTGRES_* ниже; install.sh сгенерирует автоматически)
POSTGRES_USER=tgvpn
POSTGRES_PASSWORD=change_me_strong_password
POSTGRES_DB=tgvpn
DATABASE_URL=postgres://tgvpn:change_me_strong_password@db:5432/tgvpn?sslmode=disable
# Срок подписки: для /config у пользователей бота
TRIAL_USER_DAYS=1
# Для /admin user (создание админом)
DEFAULT_USER_DAYS=1
# UUID сквадов из панели (/admin squads), через запятую для internal
DEFAULT_EXTERNAL_SQUAD_UUID=
DEFAULT_INTERNAL_SQUAD_UUIDS=
# Docker Compose: cp .env.example .env
+36 -1
View File
@@ -2,6 +2,41 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/). Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
## [Unreleased]
### Добавлено
- `/config` и кнопка «Получить конфиг» — trial-подписка на `TRIAL_USER_DAYS` (по умолчанию 1 день), создание пользователя в Remnawave и ссылка на подписку
- `install.sh` — интерактивный установщик на Linux-сервер (опрос параметров, `.env`, Docker)
- PostgreSQL 16 в Docker Compose (`DATABASE_URL`)
- Создание пользователей Remnawave: `/admin user`, `/admin user <логин> [дней]`
- Назначение сквадов: external + internal (`/admin assign <логин>`, мастер с кнопками)
- `/admin squads` — список сквадов из API
- Сохранение VPN-пользователей и состояния мастера в БД
## [0.20.0] — 2026-05-21
### Изменено
- Конфигурация Remnawave приведена к [официальной документации](https://docs.rw/docs/install/subscription-page/bundled):
- `REMNAWAVE_PANEL_URL` — URL панели и API (`/api/...`)
- `REMNAWAVE_API_TOKEN``Authorization: Bearer`
- `CADDY_AUTH_API_TOKEN``X-Api-Key` (вместо `REMNAWAVE_CADDY_TOKEN`, старое имя поддерживается)
- Удалён `REMNAWAVE_API_URL` (отдельный URL API в Remnawave не используется)
### Исправлено
- `/admin check`: отчёт без Markdown — URL и имена переменных отображаются корректно
- Страница подписки (`REMNAWAVE_SUBSCRIPTION_URL`) — опциональная проверка, не ошибка если не задана
- Подсказка при HTTP 502: различие домена панели (`panel.*`) и подписки (`sub.*`)
### Добавлено
- Раздел в README: Remnawave API (по официальной документации)
- Пример `curl` для проверки API с сервера
[0.20.0]: #
## [0.10.0-beta] — 2026-05-21 ## [0.10.0-beta] — 2026-05-21
Первый публичный beta-релиз Telegram-бота для VPN на базе [Remnawave](https://docs.rw/). Первый публичный beta-релиз Telegram-бота для VPN на базе [Remnawave](https://docs.rw/).
@@ -32,4 +67,4 @@
- `internal/config` — загрузка конфигурации - `internal/config` — загрузка конфигурации
- `internal/remnawave` — HTTP-клиент и health-check панели - `internal/remnawave` — HTTP-клиент и health-check панели
[0.10.0-beta]: https://git.evilfox.cc/test/tgvpn/releases/tag/v0.10.0-beta [0.10.0-beta]: #
+330 -26
View File
@@ -1,8 +1,20 @@
# tgvpn # tgvpn
**Версия:** [0.10.0-beta](CHANGELOG.md) · [Релизы](https://git.evilfox.cc/test/tgvpn/releases) **Версия:** [0.20.0](CHANGELOG.md)
Telegram-бот на Go (базовое приветствие; далее — VPN-функции). Telegram-бот на Go для управления VPN через панель [Remnawave](https://docs.rw/): проверка панели, создание пользователей, назначение сквадов. Данные хранятся в **PostgreSQL**.
## Содержание
- [Требования](#требования)
- [Установщик (рекомендуется)](#установщик-на-сервере)
- [Быстрый старт](#быстрый-старт-docker-compose)
- [PostgreSQL](#postgresql)
- [Развёртывание на VPS](#развёртывание-на-vps-linux)
- [Обновление бота](#обновление-бота)
- [Переменные окружения](#переменные-окружения)
- [Админ-меню](#админ-меню-в-боте)
- [Устранение неполадок](#устранение-неполадок)
## Требования ## Требования
@@ -10,6 +22,7 @@ Telegram-бот на Go (базовое приветствие; далее — V
|-----------|---------| |-----------|---------|
| Docker | 24+ | | Docker | 24+ |
| Docker Compose | v2 (`docker compose`) | | Docker Compose | v2 (`docker compose`) |
| PostgreSQL | 16+ (в compose включён) |
| Токен бота | [@BotFather](https://t.me/BotFather) | | Токен бота | [@BotFather](https://t.me/BotFather) |
| Сеть | Исходящий HTTPS к `api.telegram.org` (порт 443) | | Сеть | Исходящий HTTPS к `api.telegram.org` (порт 443) |
@@ -17,12 +30,69 @@ Telegram-бот на Go (базовое приветствие; далее — V
--- ---
## Установщик на сервере
Интерактивный скрипт запросит все параметры, создаст `.env` и запустит Docker.
### Требования на сервере
- Linux (Ubuntu 22.04/24.04, Debian 12)
- `curl`, `git` (для клонирования)
- Права `sudo` (для установки Docker при необходимости)
### Установка одной командой
Если репозиторий уже на сервере:
```bash
cd tgvpn
chmod +x install.sh
./install.sh
```
Или скачайте скрипт и укажите каталог `/opt/tgvpn`:
```bash
sudo mkdir -p /opt/tgvpn
cd /opt/tgvpn
git clone <URL-вашего-репозитория> .
chmod +x install.sh
./install.sh
```
### Что спрашивает установщик
| Блок | Параметры |
|------|-----------|
| Telegram | `BOT_TOKEN`, `TELEGRAM_ADMIN_ID`, `BOT_DEBUG` |
| Remnawave | URL панели, API token, Caddy token, subscription URL |
| PostgreSQL | пользователь, база, пароль (можно сгенерировать случайный) |
| VPN | срок по умолчанию, UUID сквадов (опционально) |
| Система | каталог установки, URL git (если не из текущей папки) |
После завершения: `docker compose up -d --build`, проверка `docker compose ps`.
### Переменные окружения для PostgreSQL в compose
В `.env` должны совпадать `POSTGRES_PASSWORD` и пароль в `DATABASE_URL`:
```env
POSTGRES_USER=tgvpn
POSTGRES_PASSWORD=ваш_сильный_пароль
POSTGRES_DB=tgvpn
DATABASE_URL=postgres://tgvpn:ваш_сильный_пароль@db:5432/tgvpn?sslmode=disable
```
Установщик заполняет это автоматически.
---
## Быстрый старт (Docker Compose) ## Быстрый старт (Docker Compose)
### 1. Клонирование ### 1. Клонирование
```bash ```bash
git clone https://git.evilfox.cc/test/tgvpn.git git clone <URL-вашего-репозитория>
cd tgvpn cd tgvpn
``` ```
@@ -42,12 +112,16 @@ REMNAWAVE_PANEL_NAME=Панель 1
REMNAWAVE_PANEL_URL=https://panel.example.com REMNAWAVE_PANEL_URL=https://panel.example.com
REMNAWAVE_API_TOKEN=токен_из_панели REMNAWAVE_API_TOKEN=токен_из_панели
REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com
DATABASE_URL=postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable
DEFAULT_USER_DAYS=30
``` ```
> **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте. > **Важно:** файл `.env` не попадает в git и не копируется в образ. Compose передаёт переменные в контейнер при старте.
### 3. Сборка и запуск ### 3. Сборка и запуск
Поднимаются два сервиса: **PostgreSQL** (`db`) и **бот** (`bot`). Бот стартует только после готовности БД.
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
@@ -55,23 +129,164 @@ docker compose up -d --build
### 4. Проверка ### 4. Проверка
```bash ```bash
# логи (должно быть: «бот @имя_бота запущен») # статус (db — healthy, bot — running)
docker compose logs -f bot
# статус контейнера
docker compose ps docker compose ps
# логи бота (должно быть: «postgres ok», «бот @имя запущен»)
docker compose logs --tail=30 bot
# логи PostgreSQL
docker compose logs --tail=20 db
``` ```
В Telegram откройте бота и отправьте `/start`. В Telegram: `/start` → кнопка «Получить конфиг» или `/config` (trial на `TRIAL_USER_DAYS`, по умолчанию 1 день). От админа — `/admin squads`, `/admin user`.
### 5. Остановка ### 5. Остановка
```bash ```bash
# остановить контейнеры (данные БД сохраняются в volume pgdata)
docker compose down docker compose down
# удалить и данные БД (осторожно!)
docker compose down -v
``` ```
--- ---
## PostgreSQL
Бот **не работает без PostgreSQL**: при старте проверяется `DATABASE_URL`, применяется миграция, далее идёт работа с БД.
### Роль базы данных
| Таблица | Назначение |
|---------|------------|
| `telegram_users` | Пользователи Telegram, зашедшие в бота (`/start`) |
| `vpn_users` | Созданные в Remnawave аккаунты: UUID, логин, сквады, срок |
| `admin_wizard` | Состояние мастера админа (создание пользователя, назначение сквадов) |
Миграции лежат в `internal/db/migrations/` и применяются **автоматически** при каждом запуске бота (`CREATE TABLE IF NOT EXISTS`).
### Схема в Docker Compose
```yaml
services:
db: # PostgreSQL 16, volume pgdata
bot: # ждёт healthy у db, затем стартует
```
Параметры по умолчанию (см. `docker-compose.yml`):
| Параметр | Значение |
|----------|----------|
| Хост (внутри compose) | `db` |
| Порт | `5432` |
| База | `tgvpn` |
| Пользователь | `tgvpn` |
| Пароль | `tgvpn` |
Строка подключения для бота:
```env
DATABASE_URL=postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable
```
Формат URL (общий вид):
```
postgres://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=disable
```
### Подключение к БД вручную
Из каталога проекта:
```bash
# интерактивная консоль psql внутри контейнера
docker compose exec db psql -U tgvpn -d tgvpn
```
Полезные запросы:
```sql
-- все VPN-пользователи, созданные через бота
SELECT remnawave_username, remnawave_uuid, expire_at, created_at FROM vpn_users;
-- активные мастера админа
SELECT admin_telegram_id, step, updated_at FROM admin_wizard;
\q
```
### Продакшен: смена пароля БД
1. Задайте сильный пароль в `docker-compose.yml` (секция `db.environment`) **или** вынесите в `.env`:
```yaml
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong}
```
2. Обновите `DATABASE_URL` в `.env` бота с тем же паролем.
3. Пересоздайте стек:
```bash
docker compose down
docker compose up -d --build
```
> Если меняете пароль у уже существующего volume `pgdata`, может понадобиться сброс volume или `ALTER USER` внутри старой БД.
### Внешний PostgreSQL (без контейнера `db`)
Если БД на отдельном сервере или managed Postgres:
1. Создайте базу и пользователя с правами `CREATE`, `SELECT`, `INSERT`, `UPDATE`, `DELETE`.
2. В `.env` укажите реальный URL:
```env
DATABASE_URL=postgres://user:password@postgres.example.com:5432/tgvpn?sslmode=require
```
3. В `docker-compose.yml` закомментируйте или удалите сервис `db` и `depends_on` у `bot`.
4. Убедитесь, что с хоста бота есть сетевой доступ к порту `5432`.
### Резервное копирование
```bash
# дамп в файл (дата в имени)
docker compose exec -T db pg_dump -U tgvpn tgvpn > backup_$(date +%Y%m%d).sql
# восстановление (на пустую или новую БД)
cat backup_20260101.sql | docker compose exec -T db psql -U tgvpn -d tgvpn
```
Рекомендуется настроить cron на VPS для ежедневных дампов.
### Проверка после деплоя
```bash
docker compose ps
# tgvpn-db running (healthy)
# tgvpn-bot running
docker compose logs bot | grep -i postgres
# ожидается успешный старт без «ping postgres» / «apply migration» ошибок
```
### Ошибки PostgreSQL
| Симптом | Решение |
|---------|---------|
| `DATABASE_URL не задан` | Добавьте переменную в `.env` |
| `connect postgres` / `ping postgres` | Проверьте, что `db` в состоянии `healthy`: `docker compose ps` |
| Бот стартует раньше БД | В compose уже есть `depends_on: condition: service_healthy` — обновите compose |
| `password authentication failed` | Совпадение пароля в `POSTGRES_PASSWORD` и в `DATABASE_URL` |
| Пустые таблицы после создания user | Смотрите логи бота и ответ Remnawave API; запись в `vpn_users` идёт после успешного `POST /api/users` |
---
## Развёртывание на VPS (Linux) ## Развёртывание на VPS (Linux)
Ниже — пошаговая установка на чистый сервер (Ubuntu 22.04/24.04, Debian 12). Аналогично на других дистрибутивах с Docker. Ниже — пошаговая установка на чистый сервер (Ubuntu 22.04/24.04, Debian 12). Аналогично на других дистрибутивах с Docker.
@@ -115,7 +330,7 @@ docker compose version
sudo mkdir -p /opt/tgvpn sudo mkdir -p /opt/tgvpn
sudo chown $USER:$USER /opt/tgvpn sudo chown $USER:$USER /opt/tgvpn
cd /opt/tgvpn cd /opt/tgvpn
git clone https://git.evilfox.cc/test/tgvpn.git . git clone <URL-вашего-репозитория> .
``` ```
### Шаг 4. Настройка `.env` ### Шаг 4. Настройка `.env`
@@ -125,7 +340,9 @@ cp .env.example .env
nano .env # или vim / vi nano .env # или vim / vi
``` ```
Укажите реальный `BOT_TOKEN`. Для продакшена оставьте `BOT_DEBUG=false`. Укажите реальный `BOT_TOKEN`, `DATABASE_URL` (в compose для VPS обычно оставляют `postgres://tgvpn:...@db:5432/...`), Remnawave и при необходимости UUID сквадов (`DEFAULT_*`).
Для продакшена смените пароль PostgreSQL — см. [PostgreSQL → Продакшен](#продакшен-смена-пароля-бд).
Права на секреты: Права на секреты:
@@ -144,6 +361,7 @@ docker compose up -d --build
```bash ```bash
docker compose ps docker compose ps
docker compose logs --tail=50 bot docker compose logs --tail=50 bot
docker compose logs --tail=20 db
``` ```
### Шаг 6. Автозапуск после перезагрузки сервера ### Шаг 6. Автозапуск после перезагрузки сервера
@@ -260,7 +478,7 @@ docker image prune -f
| `git pull` конфликтует с локальными правками | `git stash``git pull``git stash pop` или сбросить локальные изменения: `git checkout -- .` | | `git pull` конфликтует с локальными правками | `git stash``git pull``git stash pop` или сбросить локальные изменения: `git checkout -- .` |
| Бот не стартует после pull | `docker compose logs bot` — часто не хватает новой переменной в `.env` | | Бот не стартует после pull | `docker compose logs bot` — часто не хватает новой переменной в `.env` |
| Старый код в контейнере | Обязательно `--build`: `docker compose up -d --build` | | Старый код в контейнере | Обязательно `--build`: `docker compose up -d --build` |
| Нет доступа к git | Проверьте SSH/HTTPS-доступ к `git.evilfox.cc` | | Нет доступа к git | Проверьте SSH/HTTPS-доступ к вашему git-серверу |
--- ---
@@ -272,7 +490,7 @@ docker image prune -f
2. В PowerShell: 2. В PowerShell:
```powershell ```powershell
git clone https://git.evilfox.cc/test/tgvpn.git git clone <URL-вашего-репозитория>
cd tgvpn cd tgvpn
Copy-Item .env.example .env Copy-Item .env.example .env
# отредактируйте .env — вставьте BOT_TOKEN # отредактируйте .env — вставьте BOT_TOKEN
@@ -284,9 +502,31 @@ docker compose logs -f bot
## Локальная разработка (без Docker) ## Локальная разработка (без Docker)
Нужен запущенный PostgreSQL 16+ (локально или только контейнер БД):
```bash
# вариант: только БД в Docker, бот на хосте
docker compose up -d db
```
В `.env` для локального бота укажите:
```env
DATABASE_URL=postgres://tgvpn:tgvpn@localhost:5432/tgvpn?sslmode=disable
```
Проброс порта в `docker-compose.yml` (если ещё нет):
```yaml
db:
ports:
- "5432:5432"
```
Запуск:
```bash ```bash
cp .env.example .env cp .env.example .env
# укажите BOT_TOKEN в .env
go run . go run .
``` ```
@@ -306,12 +546,24 @@ go build -o bot .
| `BOT_TOKEN` | да | Токен от @BotFather | | `BOT_TOKEN` | да | Токен от @BotFather |
| `TELEGRAM_ADMIN_ID` | да | Числовой Telegram user ID администратора (например, [@userinfobot](https://t.me/userinfobot)) | | `TELEGRAM_ADMIN_ID` | да | Числовой Telegram user ID администратора (например, [@userinfobot](https://t.me/userinfobot)) |
| `REMNAWAVE_PANEL_NAME` | нет | Название панели в админ-меню (по умолчанию «Панель 1») | | `REMNAWAVE_PANEL_NAME` | нет | Название панели в админ-меню (по умолчанию «Панель 1») |
| `REMNAWAVE_PANEL_URL` | да | URL панели Remnawave, например `https://vpn.example.com` | | `REMNAWAVE_PANEL_URL` | да | URL панели — сюда же идут запросы API (`/api/...`). Пример: `https://panel.example.com` ([док](https://docs.rw/docs/install/subscription-page/bundled)) |
| `REMNAWAVE_API_TOKEN` | да | API-токен: панель → **Settings → API Tokens** ([документация](https://docs.rw/)) | | `REMNAWAVE_API_TOKEN` | да | Токен из **Remnawave Settings → API Tokens**, заголовок `Authorization: Bearer` |
| `REMNAWAVE_CADDY_TOKEN` | нет | Доп. заголовок `X-Api-Key`, если панель за Caddy | | `CADDY_AUTH_API_TOKEN` | нет | `X-Api-Key`, если включён Caddy with security (как в оф. `.env` subscription-page) |
| `REMNAWAVE_SUBSCRIPTION_URL` | нет* | URL страницы подписки для проверки в `/admin check` (*рекомендуется) | | `REMNAWAVE_SUBSCRIPTION_URL` | нет | Опционально: домен Subscription Page (`sub.*`), отдельная проверка |
| `DATABASE_URL` | да | PostgreSQL, в compose: `postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable` |
| `TRIAL_USER_DAYS` | нет | Срок trial-конфига для `/config` (по умолчанию 1) |
| `DEFAULT_USER_DAYS` | нет | Срок при создании админом `/admin user` (по умолчанию 1) |
| `DEFAULT_EXTERNAL_SQUAD_UUID` | нет | External squad по умолчанию при быстром создании |
| `DEFAULT_INTERNAL_SQUAD_UUIDS` | нет | Internal squads через запятую |
| `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) | | `BOT_DEBUG` | нет | `true` — подробные логи Telegram API (только для отладки) |
### Команды для пользователей
- `/start` — приветствие и кнопка получения конфига
- `/config` — создать пользователя в Remnawave на `TRIAL_USER_DAYS` (если активная подписка уже есть — вернёт существующую ссылку)
Нужны `DEFAULT_EXTERNAL_SQUAD_UUID` и `DEFAULT_INTERNAL_SQUAD_UUIDS` — те же сквады, что для быстрого `/admin user`.
### Админ-меню в боте ### Админ-меню в боте
Только пользователь с `TELEGRAM_ADMIN_ID`: Только пользователь с `TELEGRAM_ADMIN_ID`:
@@ -319,7 +571,36 @@ go build -o bot .
- `/admin` — админ-меню (панель 1, Remnawave) - `/admin` — админ-меню (панель 1, Remnawave)
- `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки - `/admin check` — полная проверка: веб панели, API (статистика, users, nodes), подписка (settings + API), страница подписки
- `/admin config` — конфиг панели в боте - `/admin config` — конфиг панели в боте
- Кнопки снизу (после `/start`): «Проверить панель», «Конфиг панели» - `/admin user` — мастер создания пользователя в Remnawave + назначение сквадов
- `/admin user <логин> [дней]` — быстрое создание (сквады из `DEFAULT_*` в `.env`)
- `/admin squads` — список internal/external squads
- `/admin assign <логин>` — назначить сквады существующему пользователю
- Кнопки: «Создать пользователя», «Сквады», «Проверить панель», «Конфиг»
---
## Remnawave API (по официальной документации)
Как в [Bundled Subscription Page](https://docs.rw/docs/install/subscription-page/bundled):
```env
REMNAWAVE_PANEL_URL=https://panel.example.com
REMNAWAVE_API_TOKEN=API_TOKEN_FROM_REMNAWAVE
CADDY_AUTH_API_TOKEN=
```
- **Отдельного `REMNAWAVE_API_URL` нет** — API всегда на том же хосте, что и панель: `{REMNAWAVE_PANEL_URL}/api/...`
- Авторизация: `Authorization: Bearer {REMNAWAVE_API_TOKEN}`
- Внутри Docker-сети Remnawave: `REMNAWAVE_PANEL_URL=http://remnawave:3000`
- Домен `sub.*` — это Subscription Page, не панель; для API используйте `panel.*`
Пример проверки с сервера:
```bash
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $REMNAWAVE_API_TOKEN" \
"$REMNAWAVE_PANEL_URL/api/system/stats/recap"
```
--- ---
@@ -335,10 +616,16 @@ docker compose logs -f bot
# последние 100 строк логов # последние 100 строк логов
docker compose logs --tail=100 bot docker compose logs --tail=100 bot
# зайти в контейнер (обычно не нужно) # зайти в контейнер бота
docker compose exec bot sh docker compose exec bot sh
# удалить контейнер (образ останется) # консоль PostgreSQL
docker compose exec db psql -U tgvpn -d tgvpn
# дамп базы
docker compose exec -T db pg_dump -U tgvpn tgvpn > backup.sql
# удалить контейнеры (данные БД в volume сохранятся)
docker compose down docker compose down
# удалить контейнер и неиспользуемые образы проекта # удалить контейнер и неиспользуемые образы проекта
@@ -351,8 +638,9 @@ docker compose down --rmi local
- Бот использует **long polling**: входящие запросы на ваш сервер **не нужны**, порты открывать не требуется. - Бот использует **long polling**: входящие запросы на ваш сервер **не нужны**, порты открывать не требуется.
- Нужен только **исходящий** доступ к `https://api.telegram.org`. - Нужен только **исходящий** доступ к `https://api.telegram.org`.
- Не коммитьте `.env` в git. Не публикуйте `BOT_TOKEN`. - Не коммитьте `.env` в git. Не публикуйте `BOT_TOKEN` и пароль БД.
- Контейнер запускается от непривилегированного пользователя `bot` (UID 10001). - PostgreSQL доступен **только внутри docker-сети** (порт наружу не проброшен по умолчанию).
- Контейнер бота запускается от непривилегированного пользователя `bot` (UID 10001).
Если позже добавите **webhook**, понадобится reverse proxy (nginx/Caddy), TLS и открытый порт 443 — это описывается отдельно при появлении функции. Если позже добавите **webhook**, понадобится reverse proxy (nginx/Caddy), TLS и открытый порт 443 — это описывается отдельно при появлении функции.
@@ -381,6 +669,15 @@ docker compose logs bot # ошибки сети, токена
- Убедитесь, что на сервере нет блокировки Telegram (firewall, провайдер). - Убедитесь, что на сервере нет блокировки Telegram (firewall, провайдер).
- Проверьте: `curl -I https://api.telegram.org` с хоста. - Проверьте: `curl -I https://api.telegram.org` с хоста.
### API возвращает 502, веб-панель — 200
Частая причина: в `REMNAWAVE_PANEL_URL` указан домен **страницы подписки** (`sub.example.com`), а не **админ-панели** (`panel.example.com`).
1. Укажите URL **панели** (не sub): `REMNAWAVE_PANEL_URL=https://panel.example.com`
2. Токен API: `REMNAWAVE_API_TOKEN=...` (Settings → API Tokens)
3. Страницу подписки — опционально: `REMNAWAVE_SUBSCRIPTION_URL=https://sub.example.com`
4. Проверьте на сервере: `docker compose ps` (Remnawave Panel запущен), логи reverse proxy
### Контейнер постоянно перезапускается ### Контейнер постоянно перезапускается
```bash ```bash
@@ -389,6 +686,10 @@ docker compose logs --tail=200 bot
Чаще всего — пустой `BOT_TOKEN` или ошибка при старте. Чаще всего — пустой `BOT_TOKEN` или ошибка при старте.
### `DATABASE_URL не задан` / ошибки PostgreSQL
См. раздел [PostgreSQL → Ошибки](#ошибки-postgresql).
### Нет доступа к `docker` без sudo ### Нет доступа к `docker` без sudo
```bash ```bash
@@ -404,11 +705,14 @@ sudo usermod -aG docker $USER
tgvpn/ tgvpn/
├── main.go ├── main.go
├── internal/ ├── internal/
│ ├── bot/ # обработчики Telegram, админ-меню │ ├── bot/ # Telegram, админ-меню, создание пользователей
│ ├── config/ # переменные окружения │ ├── config/ # переменные окружения
── remnawave/ # клиент API панели ── db/ # PostgreSQL: подключение, миграции, репозитории
│ │ └── migrations/ # SQL-миграции (001_init.sql)
│ └── remnawave/ # API панели (users, squads)
├── Dockerfile # multi-stage сборка ├── Dockerfile # multi-stage сборка
├── docker-compose.yml # оркестрация ├── install.sh # интерактивный установщик на сервер
├── docker-compose.yml # bot + PostgreSQL (volume pgdata)
├── .env.example # шаблон переменных ├── .env.example # шаблон переменных
├── .dockerignore ├── .dockerignore
├── go.mod / go.sum ├── go.mod / go.sum
@@ -420,4 +724,4 @@ tgvpn/
## Репозиторий ## Репозиторий
https://git.evilfox.cc/test/tgvpn.git Укажите URL вашего приватного git-репозитория при клонировании.
+25 -2
View File
@@ -1,4 +1,22 @@
services: services:
db:
image: postgres:16-alpine
container_name: tgvpn-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-tgvpn}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tgvpn}
POSTGRES_DB: ${POSTGRES_DB:-tgvpn}
env_file:
- .env
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tgvpn -d tgvpn"]
interval: 5s
timeout: 5s
retries: 5
bot: bot:
build: build:
context: . context: .
@@ -6,9 +24,14 @@ services:
image: tgvpn-bot:latest image: tgvpn-bot:latest
container_name: tgvpn-bot container_name: tgvpn-bot
restart: unless-stopped restart: unless-stopped
depends_on:
db:
condition: service_healthy
env_file: env_file:
- .env - .env
environment: environment:
BOT_DEBUG: ${BOT_DEBUG:-false} BOT_DEBUG: ${BOT_DEBUG:-false}
# Long polling — исходящие HTTPS к api.telegram.org DATABASE_URL: ${DATABASE_URL:-postgres://tgvpn:tgvpn@db:5432/tgvpn?sslmode=disable}
# ports не нужны, пока нет webhook
volumes:
pgdata:
+10 -1
View File
@@ -1,8 +1,17 @@
module telegramvpn module telegramvpn
go 1.22 go 1.25.0
require ( require (
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/jackc/pgx/v5 v5.9.2
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
) )
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+26
View File
@@ -1,4 +1,30 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env bash
# Интерактивная установка tgvpn на Linux-сервер (Docker + PostgreSQL)
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/tgvpn}"
REPO_URL=""
USE_CURRENT_DIR=false
SKIP_DOCKER_INSTALL=false
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${CYAN}[*]${NC} $*"; }
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
fail() { echo -e "${RED}[✗]${NC} $*" >&2; exit 1; }
prompt() {
local label="$1"
local default="${2:-}"
local val
if [[ -n "$default" ]]; then
read -r -p "$label [$default]: " val
echo "${val:-$default}"
else
read -r -p "$label: " val
echo "$val"
fi
}
prompt_required() {
local label="$1"
local val=""
while [[ -z "$val" ]]; do
read -r -p "$label: " val
[[ -z "$val" ]] && warn "Поле обязательно."
done
echo "$val"
}
prompt_secret() {
local label="$1"
local val=""
while [[ -z "$val" ]]; do
read -r -s -p "$label: " val
echo ""
[[ -z "$val" ]] && warn "Поле обязательно."
done
echo "$val"
}
prompt_yn() {
local label="$1"
local default="${2:-y}"
local hint="Y/n"
[[ "$default" == "n" ]] && hint="y/N"
local ans
read -r -p "$label [$hint]: " ans
ans="${ans:-$default}"
[[ "$ans" =~ ^[Yy] ]]
}
need_cmd() {
command -v "$1" >/dev/null 2>&1
}
check_docker() {
need_cmd docker && docker compose version >/dev/null 2>&1
}
install_docker() {
info "Установка Docker (официальный репозиторий)..."
if need_cmd apt-get; then
sudo apt-get update -qq
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${VERSION_CODENAME}") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
fi
sudo apt-get update -qq
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER" 2>/dev/null || true
ok "Docker установлен. Если команда docker требует sudo — перелогиньтесь или выполните: newgrp docker"
else
fail "Автоустановка Docker поддерживается только для Debian/Ubuntu (apt). Установите Docker вручную: https://docs.docker.com/engine/install/"
fi
}
write_env() {
local env_file="$1"
cat > "$env_file" <<EOF
# Сгенерировано install.sh $(date -Iseconds)
BOT_TOKEN=${BOT_TOKEN}
BOT_DEBUG=${BOT_DEBUG}
TELEGRAM_ADMIN_ID=${TELEGRAM_ADMIN_ID}
REMNAWAVE_PANEL_NAME=${REMNAWAVE_PANEL_NAME}
REMNAWAVE_PANEL_URL=${REMNAWAVE_PANEL_URL}
REMNAWAVE_API_TOKEN=${REMNAWAVE_API_TOKEN}
CADDY_AUTH_API_TOKEN=${CADDY_AUTH_API_TOKEN}
REMNAWAVE_SUBSCRIPTION_URL=${REMNAWAVE_SUBSCRIPTION_URL}
POSTGRES_USER=${POSTGRES_USER}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB=${POSTGRES_DB}
DATABASE_URL=${DATABASE_URL}
TRIAL_USER_DAYS=${TRIAL_USER_DAYS}
DEFAULT_USER_DAYS=${DEFAULT_USER_DAYS}
DEFAULT_EXTERNAL_SQUAD_UUID=${DEFAULT_EXTERNAL_SQUAD_UUID}
DEFAULT_INTERNAL_SQUAD_UUIDS=${DEFAULT_INTERNAL_SQUAD_UUIDS}
EOF
chmod 600 "$env_file"
}
banner() {
echo ""
echo "========================================"
echo " tgvpn — установщик Telegram-бота"
echo " Remnawave + PostgreSQL + Docker"
echo "========================================"
echo ""
}
validate_db_password() {
if [[ "$POSTGRES_PASSWORD" =~ [:@/?#\[\] ] ]]; then
warn "В пароле PostgreSQL есть спецсимволы. Рекомендуется сгенерировать пароль (буквы/цифры)."
if ! prompt_yn "Продолжить с этим паролем?" "n"; then
fail "Укажите другой пароль или сгенерируйте автоматически."
fi
fi
}
main() {
banner
local script_dir
script_dir="$(cd "$(dirname "$0")" && pwd)"
if [[ -f "$script_dir/docker-compose.yml" ]] && [[ -f "$script_dir/main.go" ]]; then
if prompt_yn "Запустить установку из текущей папки ($script_dir)?" "y"; then
USE_CURRENT_DIR=true
INSTALL_DIR="$script_dir"
fi
fi
if ! $USE_CURRENT_DIR; then
INSTALL_DIR="$(prompt "Каталог установки" "$INSTALL_DIR")"
REPO_URL="$(prompt "URL git-репозитория (пусто — только каталог, код уже должен быть там)" "")"
fi
echo ""
info "=== Telegram ==="
BOT_TOKEN="$(prompt_secret "BOT_TOKEN (от @BotFather)")"
TELEGRAM_ADMIN_ID="$(prompt_required "TELEGRAM_ADMIN_ID (числовой ID от @userinfobot)")"
if [[ ! "$TELEGRAM_ADMIN_ID" =~ ^[0-9]+$ ]]; then
fail "TELEGRAM_ADMIN_ID должен быть числом"
fi
if prompt_yn "Включить BOT_DEBUG (подробные логи)?" "n"; then
BOT_DEBUG=true
else
BOT_DEBUG=false
fi
echo ""
info "=== Remnawave ==="
REMNAWAVE_PANEL_NAME="$(prompt "Название панели в боте" "Панель 1")"
REMNAWAVE_PANEL_URL="$(prompt_required "REMNAWAVE_PANEL_URL (https://panel.example.com)")"
if [[ ! "$REMNAWAVE_PANEL_URL" =~ ^https?:// ]]; then
fail "URL панели должен начинаться с http:// или https://"
fi
REMNAWAVE_PANEL_URL="${REMNAWAVE_PANEL_URL%/}"
REMNAWAVE_API_TOKEN="$(prompt_secret "REMNAWAVE_API_TOKEN (Settings → API Tokens)")"
CADDY_AUTH_API_TOKEN="$(prompt "CADDY_AUTH_API_TOKEN (опционально, Enter — пропустить)" "")"
REMNAWAVE_SUBSCRIPTION_URL="$(prompt "REMNAWAVE_SUBSCRIPTION_URL (опционально)" "")"
if [[ -n "$REMNAWAVE_SUBSCRIPTION_URL" ]]; then
REMNAWAVE_SUBSCRIPTION_URL="${REMNAWAVE_SUBSCRIPTION_URL%/}"
fi
echo ""
info "=== PostgreSQL ==="
POSTGRES_USER="$(prompt "Пользователь БД" "tgvpn")"
POSTGRES_DB="$(prompt "Имя базы" "tgvpn")"
if prompt_yn "Сгенерировать случайный пароль PostgreSQL?" "y"; then
POSTGRES_PASSWORD="$(openssl rand -hex 16 2>/dev/null || head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 24)"
ok "Пароль сгенерирован (сохранён в .env)"
else
POSTGRES_PASSWORD="$(prompt_secret "POSTGRES_PASSWORD")"
fi
validate_db_password
DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable"
echo ""
info "=== Пользователи VPN (по умолчанию) ==="
TRIAL_USER_DAYS="$(prompt "Срок trial-конфига для пользователей бота (/config), дней" "1")"
DEFAULT_USER_DAYS="$(prompt "Срок при создании админом (/admin user), дней" "1")"
DEFAULT_EXTERNAL_SQUAD_UUID="$(prompt "DEFAULT_EXTERNAL_SQUAD_UUID (опционально)" "")"
DEFAULT_INTERNAL_SQUAD_UUIDS="$(prompt "DEFAULT_INTERNAL_SQUAD_UUIDS через запятую (опционально)" "")"
echo ""
if ! check_docker; then
warn "Docker или Docker Compose не найдены."
if prompt_yn "Установить Docker автоматически (Ubuntu/Debian)?" "y"; then
install_docker
if ! check_docker; then
warn "После установки может потребоваться перелогин. Запустите скрипт снова или: sudo docker compose ..."
SKIP_DOCKER_INSTALL=true
fi
else
fail "Нужны Docker и плагин compose. Установите вручную и запустите скрипт снова."
fi
else
ok "Docker и Docker Compose найдены"
fi
echo ""
info "=== Установка файлов ==="
if ! $USE_CURRENT_DIR; then
sudo mkdir -p "$INSTALL_DIR"
sudo chown "$USER:$USER" "$INSTALL_DIR"
if [[ -n "$REPO_URL" ]]; then
if [[ -d "$INSTALL_DIR/.git" ]]; then
info "Обновление репозитория..."
git -C "$INSTALL_DIR" pull --ff-only || warn "git pull не выполнен"
else
git clone "$REPO_URL" "$INSTALL_DIR"
fi
elif [[ ! -f "$INSTALL_DIR/docker-compose.yml" ]]; then
fail "В $INSTALL_DIR нет docker-compose.yml. Укажите URL репозитория или запустите скрипт из папки проекта."
fi
fi
cd "$INSTALL_DIR"
if [[ -f .env ]]; then
if ! prompt_yn ".env уже существует. Перезаписать?" "n"; then
fail "Установка отменена. Отредактируйте .env вручную."
fi
cp .env ".env.bak.$(date +%s)" 2>/dev/null || true
warn "Старая копия: .env.bak.*"
fi
write_env ".env"
ok "Создан .env"
echo ""
info "=== Запуск контейнеров ==="
DC="docker compose"
if ! docker compose version >/dev/null 2>&1; then
DC="sudo docker compose"
fi
$DC pull db 2>/dev/null || true
$DC up -d --build
echo ""
sleep 3
$DC ps
echo ""
ok "Установка завершена!"
echo ""
echo " Каталог: $INSTALL_DIR"
echo " Логи бота: $DC logs -f bot"
echo " Логи БД: $DC logs -f db"
echo " Перезапуск: $DC up -d --build"
echo ""
echo " В Telegram (аккаунт админа $TELEGRAM_ADMIN_ID):"
echo " /start"
echo " /admin check"
echo " /admin squads"
echo " /admin user"
echo ""
}
main "$@"
+407
View File
@@ -0,0 +1,407 @@
package bot
import (
"context"
"fmt"
"log"
"regexp"
"strconv"
"strings"
"time"
"telegramvpn/internal/db"
"telegramvpn/internal/remnawave"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,36}$`)
func (h *Handler) handleAdminUsersSubcommand(chatID, adminID int64, args []string) {
switch {
case len(args) == 0 || args[0] == "help":
h.sendText(chatID, "Пользователи Remnawave:\n\n"+
"/admin user — мастер создания\n"+
"/admin user <логин> [дней] — быстрое создание\n"+
"/admin squads — список сквадов\n"+
"/admin assign <логин> — назначить сквады (мастер)\n"+
"/admin cancel — отменить мастер")
case args[0] == "cancel", args[0] == "отмена":
_ = h.database.ClearWizard(context.Background(), adminID)
h.sendText(chatID, "Мастер отменён.")
case args[0] == "squads", args[0] == "сквады":
h.sendSquadsList(chatID)
case args[0] == "user", args[0] == "пользователь":
if len(args) >= 2 {
days := h.cfg.DefaultUserDays
if len(args) >= 3 {
if d, err := strconv.Atoi(args[2]); err == nil && d > 0 {
days = d
}
}
h.quickCreateUser(chatID, adminID, args[1], days)
} else {
h.startUserWizard(chatID, adminID)
}
case args[0] == "assign", args[0] == "сквад":
if len(args) < 2 {
h.sendText(chatID, "Укажите логин: /admin assign username")
return
}
h.startAssignWizard(chatID, adminID, args[1])
default:
h.sendAdminHelp(chatID)
}
}
func (h *Handler) startUserWizard(chatID, adminID int64) {
ctx := context.Background()
data := db.WizardData{"mode": "create"}
_ = h.database.SetWizard(ctx, adminID, db.StepAwaitUsername, data)
h.sendText(chatID, "Создание пользователя.\n\nВведите логин (3–36 символов, a-z, 0-9, _, -):\n\n/admin cancel — отмена")
}
func (h *Handler) startAssignWizard(chatID, adminID int64, username string) {
ctx := context.Background()
data := db.WizardData{
"mode": "assign",
"username": username,
}
_ = h.database.SetWizard(ctx, adminID, db.StepPickExternalSquad, data)
h.sendExternalSquadPicker(chatID, data)
}
func (h *Handler) handleWizardMessage(chatID, adminID int64, text string) bool {
ctx := context.Background()
w, err := h.database.GetWizard(ctx, adminID)
if err != nil || w == nil || w.Step == db.StepIdle || w.Step == "" {
return false
}
switch w.Step {
case db.StepAwaitUsername:
if !usernameRe.MatchString(text) {
h.sendText(chatID, "Неверный логин. Допустимы: a-z, 0-9, _, - (336 символов).")
return true
}
w.Data.Set("username", text)
_ = h.database.SetWizard(ctx, adminID, db.StepAwaitDays, w.Data)
h.sendText(chatID, fmt.Sprintf("Срок подписки в днях (по умолчанию %d):", h.cfg.DefaultUserDays))
return true
case db.StepAwaitDays:
days := h.cfg.DefaultUserDays
if text != "" {
if d, err := strconv.Atoi(text); err != nil || d <= 0 {
h.sendText(chatID, "Введите число дней больше 0.")
return true
} else {
days = d
}
}
w.Data.Set("days", days)
_ = h.database.SetWizard(ctx, adminID, db.StepPickExternalSquad, w.Data)
h.sendExternalSquadPicker(chatID, w.Data)
return true
}
return false
}
func (h *Handler) handleWizardCallback(cq *tgbotapi.CallbackQuery) bool {
if !strings.HasPrefix(cq.Data, "wz:") {
return false
}
chatID := cq.Message.Chat.ID
adminID := cq.From.ID
ctx := context.Background()
w, err := h.database.GetWizard(ctx, adminID)
if err != nil || w == nil {
return true
}
parts := strings.Split(cq.Data, ":")
if len(parts) < 2 {
return true
}
switch parts[1] {
case "ext":
if len(parts) < 3 {
return true
}
if parts[2] == "skip" {
w.Data.Set("external_squad", "")
} else {
w.Data.Set("external_squad", parts[2])
}
_ = h.database.SetWizard(ctx, adminID, db.StepPickInternalSquads, w.Data)
h.sendInternalSquadPicker(chatID, w.Data)
case "int":
if len(parts) < 3 {
return true
}
if parts[2] == "done" {
_ = h.database.SetWizard(ctx, adminID, db.StepConfirm, w.Data)
h.sendConfirm(chatID, w.Data)
return true
}
w.Data.ToggleUUID("internal_squads", parts[2])
_ = h.database.SetWizard(ctx, adminID, db.StepPickInternalSquads, w.Data)
h.sendInternalSquadPicker(chatID, w.Data)
case "ok":
h.finishWizard(chatID, adminID, w.Data)
case "no":
_ = h.database.ClearWizard(ctx, adminID)
h.sendText(chatID, "Отменено.")
}
return true
}
func (h *Handler) sendSquadsList(chatID int64) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
ext, err1 := h.panel.ListExternalSquads(ctx)
ints, err2 := h.panel.ListInternalSquads(ctx)
var b strings.Builder
b.WriteString("Сквады Remnawave:\n\n")
b.WriteString("External:\n")
if err1 != nil {
b.WriteString(" ошибка: " + err1.Error() + "\n")
} else if len(ext) == 0 {
b.WriteString(" (пусто)\n")
} else {
for _, s := range ext {
b.WriteString(fmt.Sprintf(" • %s\n %s\n", s.Name, s.UUID))
}
}
b.WriteString("\nInternal:\n")
if err2 != nil {
b.WriteString(" ошибка: " + err2.Error() + "\n")
} else if len(ints) == 0 {
b.WriteString(" (пусто)\n")
} else {
for _, s := range ints {
b.WriteString(fmt.Sprintf(" • %s\n %s\n", s.Name, s.UUID))
}
}
h.sendText(chatID, b.String())
}
func (h *Handler) sendExternalSquadPicker(chatID int64, data db.WizardData) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
squads, err := h.panel.ListExternalSquads(ctx)
if err != nil {
h.sendText(chatID, "Не удалось загрузить external squads: "+err.Error())
return
}
var rows [][]tgbotapi.InlineKeyboardButton
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("⏭ Без external squad", "wz:ext:skip"),
))
for _, s := range squads {
label := s.Name
if len(label) > 40 {
label = label[:40]
}
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(label, "wz:ext:"+s.UUID),
))
}
msg := tgbotapi.NewMessage(chatID, "Выберите External Squad:")
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...)
h.send(msg)
}
func (h *Handler) sendInternalSquadPicker(chatID int64, data db.WizardData) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
squads, err := h.panel.ListInternalSquads(ctx)
if err != nil {
h.sendText(chatID, "Не удалось загрузить internal squads: "+err.Error())
return
}
selected := map[string]bool{}
for _, id := range data.StringSlice("internal_squads") {
selected[id] = true
}
var rows [][]tgbotapi.InlineKeyboardButton
for _, s := range squads {
mark := "☐"
if selected[s.UUID] {
mark = "☑"
}
label := fmt.Sprintf("%s %s", mark, s.Name)
if len(label) > 60 {
label = label[:60]
}
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(label, "wz:int:"+s.UUID),
))
}
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("✅ Готово", "wz:int:done"),
))
msg := tgbotapi.NewMessage(chatID, "Выберите Internal Squads (можно несколько), затем «Готово»:")
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...)
h.send(msg)
}
func (h *Handler) sendConfirm(chatID int64, data db.WizardData) {
ext := data.String("external_squad")
ints := data.StringSlice("internal_squads")
text := fmt.Sprintf(
"Подтвердите:\n\nЛогин: %s\nДней: %d\nExternal: %s\nInternal: %d шт.\n",
data.String("username"), data.Int("days"), squadLabel(ext), len(ints),
)
if data.String("mode") == "assign" {
text = fmt.Sprintf(
"Назначить сквады пользователю %s\n\nExternal: %s\nInternal: %d шт.\n",
data.String("username"), squadLabel(ext), len(ints),
)
}
msg := tgbotapi.NewMessage(chatID, text)
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("✅ Да", "wz:ok"),
tgbotapi.NewInlineKeyboardButtonData("❌ Нет", "wz:no"),
),
)
h.send(msg)
}
func (h *Handler) finishWizard(chatID, adminID int64, data db.WizardData) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ext := data.String("external_squad")
var extPtr *string
if ext != "" {
extPtr = &ext
}
ints := data.StringSlice("internal_squads")
if len(ints) == 0 && len(h.cfg.DefaultInternalSquadUUIDs) > 0 {
ints = h.cfg.DefaultInternalSquadUUIDs
}
if extPtr == nil && h.cfg.DefaultExternalSquadUUID != "" {
e := h.cfg.DefaultExternalSquadUUID
extPtr = &e
}
mode := data.String("mode")
if mode == "assign" {
u, err := h.panel.AssignSquads(ctx, remnawave.AssignSquadsInput{
Username: data.String("username"),
ExternalSquadUUID: extPtr,
ActiveInternalSquads: ints,
})
_ = h.database.ClearWizard(ctx, adminID)
if err != nil {
h.sendText(chatID, "Ошибка назначения сквадов: "+err.Error())
return
}
h.sendText(chatID, fmt.Sprintf("✅ Сквады назначены пользователю %s\nUUID: %s", u.Username, u.UUID))
return
}
days := data.Int("days")
if days <= 0 {
days = h.cfg.DefaultUserDays
}
var tgID *int64
// при создании из мастера админом telegramId не обязателен
u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{
Username: data.String("username"),
ExpireAt: db.DefaultExpireAt(days),
TelegramID: tgID,
ExternalSquadUUID: extPtr,
ActiveInternalSquads: ints,
Description: "created via tgvpn bot",
})
_ = h.database.ClearWizard(ctx, adminID)
if err != nil {
h.sendText(chatID, "Ошибка создания: "+err.Error())
return
}
vpn := db.VPNUser{
RemnawaveUUID: u.UUID,
RemnawaveUsername: u.Username,
ExternalSquadUUID: extPtr,
InternalSquadUUIDs: ints,
ExpireAt: &u.ExpireAt,
}
if err := h.database.SaveVPNUser(ctx, vpn); err != nil {
log.Printf("save vpn user: %v", err)
}
text := fmt.Sprintf("✅ Пользователь создан\n\nЛогин: %s\nUUID: %s\nИстекает: %s",
u.Username, u.UUID, u.ExpireAt.Format("2006-01-02"))
if u.SubscriptionURL != "" {
text += "\nПодписка: " + u.SubscriptionURL
} else if u.ShortUUID != "" && h.cfg.RemnawaveSubscription != "" {
text += "\nПодписка: " + h.cfg.RemnawaveSubscription + "/" + u.ShortUUID
}
h.sendText(chatID, text)
}
func (h *Handler) quickCreateUser(chatID, adminID int64, username string, days int) {
if !usernameRe.MatchString(username) {
h.sendText(chatID, "Неверный логин.")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var extPtr *string
if h.cfg.DefaultExternalSquadUUID != "" {
e := h.cfg.DefaultExternalSquadUUID
extPtr = &e
}
ints := h.cfg.DefaultInternalSquadUUIDs
u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{
Username: username,
ExpireAt: db.DefaultExpireAt(days),
ExternalSquadUUID: extPtr,
ActiveInternalSquads: ints,
Description: "created via tgvpn bot",
})
if err != nil {
h.sendText(chatID, "Ошибка: "+err.Error())
return
}
_ = h.database.SaveVPNUser(ctx, db.VPNUser{
RemnawaveUUID: u.UUID,
RemnawaveUsername: u.Username,
ExternalSquadUUID: extPtr,
InternalSquadUUIDs: ints,
ExpireAt: &u.ExpireAt,
})
h.sendText(chatID, fmt.Sprintf("✅ %s создан до %s", u.Username, u.ExpireAt.Format("2006-01-02")))
}
func squadLabel(uuid string) string {
if uuid == "" {
return "—"
}
if len(uuid) > 12 {
return uuid[:8] + "…"
}
return uuid
}
+166 -97
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"telegramvpn/internal/config" "telegramvpn/internal/config"
"telegramvpn/internal/db"
"telegramvpn/internal/remnawave" "telegramvpn/internal/remnawave"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -19,30 +20,33 @@ type Handler struct {
cfg *config.Config cfg *config.Config
api *tgbotapi.BotAPI api *tgbotapi.BotAPI
panel *remnawave.Client panel *remnawave.Client
database *db.DB
admin int64 admin int64
} }
func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI) *Handler { func NewHandler(cfg *config.Config, api *tgbotapi.BotAPI, database *db.DB) *Handler {
return &Handler{ return &Handler{
cfg: cfg, cfg: cfg,
api: api, api: api,
panel: remnawave.NewClient(cfg.RemnawaveURL, cfg.RemnawaveToken, cfg.RemnawaveCaddy), panel: remnawave.NewClient(cfg.RemnawavePanelURL, cfg.RemnawaveAPIToken, cfg.CaddyAuthAPIToken),
database: database,
admin: cfg.TelegramAdminID, admin: cfg.TelegramAdminID,
} }
} }
func (h *Handler) RegisterCommands() { func (h *Handler) RegisterCommands() {
commands := []tgbotapi.BotCommand{ public := []tgbotapi.BotCommand{
{Command: "start", Description: "Начать"}, {Command: "start", Description: "Начать"},
{Command: "admin", Description: "Админ-меню Remnawave (панель 1)"}, {Command: "config", Description: "Получить VPN-конфиг"},
} }
if _, err := h.api.Request(tgbotapi.NewSetMyCommands(public...)); err != nil {
log.Printf("команды (все пользователи): %v", err)
}
admin := append(public, tgbotapi.BotCommand{Command: "admin", Description: "Админ-меню"})
scope := tgbotapi.BotCommandScope{Type: "chat", ChatID: h.admin} scope := tgbotapi.BotCommandScope{Type: "chat", ChatID: h.admin}
cfg := tgbotapi.SetMyCommandsConfig{ if _, err := h.api.Request(tgbotapi.SetMyCommandsConfig{Commands: admin, Scope: &scope}); err != nil {
Commands: commands, log.Printf("команды (админ): %v", err)
Scope: &scope,
}
if _, err := h.api.Request(cfg); err != nil {
log.Printf("не удалось зарегистрировать команды для админа: %v", err)
} }
} }
@@ -61,26 +65,36 @@ func (h *Handler) HandleUpdate(update tgbotapi.Update) {
switch { switch {
case text == "/start": case text == "/start":
h.sendStart(chatID, userID, update.Message.From.FirstName) h.sendStart(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName)
case text == "/config", text == "/getconfig":
h.handleUserConfig(chatID, userID)
case strings.HasPrefix(text, "/admin"): case strings.HasPrefix(text, "/admin"):
h.handleAdminCommand(chatID, userID, text) h.handleAdminCommand(chatID, userID, text)
case strings.HasPrefix(text, "/"): case strings.HasPrefix(text, "/"):
h.sendText(chatID, "Неизвестная команда. Для начала — /start") h.sendText(chatID, "Неизвестная команда. Для начала — /start")
default: default:
if h.isAdmin(userID) { if h.isAdmin(userID) && h.handleWizardMessage(chatID, userID, text) {
return
}
switch text { switch text {
case "📋 Конфиг панели": case userHomeLabel(), "/menu":
h.sendPanelConfig(chatID) h.sendUserMenu(chatID, userID, update.Message.From.FirstName, update.Message.From.UserName)
return return
case "🔌 Проверить панель": case adminPanelLabel(), "🛠 Админ-меню":
h.sendPanelCheck(chatID) if h.isAdmin(userID) {
return h.sendAdminMenu(chatID)
case "◀️ Выйти из админки": }
h.sendText(chatID, "Админ-меню закрыто. /admin — снова открыть.")
return return
} }
if h.isUserConfigButtonText(text) {
h.handleUserConfig(chatID, userID)
return
} }
h.sendText(chatID, "Напишите /start, чтобы начать.") // Старые подписи reply-клавиатуры (если остались у пользователя)
if h.isAdmin(userID) && h.handleLegacyAdminReply(chatID, userID, text) {
return
}
h.sendText(chatID, "Напишите /start или нажмите 🏠 Главная в меню.")
} }
} }
@@ -101,6 +115,8 @@ func (h *Handler) handleAdminCommand(chatID, userID int64, text string) {
h.sendPanelCheck(chatID) h.sendPanelCheck(chatID)
case "config", "конфиг": case "config", "конфиг":
h.sendPanelConfig(chatID) h.sendPanelConfig(chatID)
case "user", "пользователь", "squads", "сквады", "assign", "сквад", "cancel", "отмена", "help":
h.handleAdminUsersSubcommand(chatID, userID, args[2:])
default: default:
h.sendAdminHelp(chatID) h.sendAdminHelp(chatID)
} }
@@ -112,20 +128,54 @@ func (h *Handler) handleCallback(cq *tgbotapi.CallbackQuery) {
log.Printf("callback answer: %v", err) log.Printf("callback answer: %v", err)
} }
if !h.isAdmin(cq.From.ID) { chatID := cq.Message.Chat.ID
h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Нет доступа.") userID := cq.From.ID
switch cq.Data {
case cbUserConfig:
h.handleUserConfig(chatID, userID)
return
case cbUserHome:
h.sendUserMenu(chatID, userID, cq.From.FirstName, cq.From.UserName)
return
}
if strings.HasPrefix(cq.Data, "wz:") {
if !h.isAdmin(userID) {
h.callbackDenied(cq)
return
}
if h.handleWizardCallback(cq) {
return
}
}
if !h.isAdmin(userID) {
h.callbackDenied(cq)
return return
} }
switch cq.Data { switch cq.Data {
case "admin:config": case cbAdminUser:
h.sendPanelConfig(cq.Message.Chat.ID) h.startUserWizard(chatID, userID)
case "admin:check": case cbAdminSquads:
h.sendPanelCheck(cq.Message.Chat.ID) h.sendSquadsList(chatID)
case "admin:menu": case cbAdminConfig:
h.sendAdminMenu(cq.Message.Chat.ID) h.sendPanelConfig(chatID)
case cbAdminCheck:
h.sendPanelCheck(chatID)
case cbAdminMenu:
h.sendAdminMenu(chatID)
default: default:
h.editOrSend(cq.Message.Chat.ID, cq.Message.MessageID, "Неизвестное действие.") h.editOrSend(chatID, cq.Message.MessageID, "Неизвестное действие.")
}
}
func (h *Handler) callbackDenied(cq *tgbotapi.CallbackQuery) {
cb := tgbotapi.NewCallback(cq.ID, "Нет доступа")
cb.ShowAlert = true
if _, err := h.api.Request(cb); err != nil {
log.Printf("callback alert: %v", err)
} }
} }
@@ -133,65 +183,120 @@ func (h *Handler) isAdmin(userID int64) bool {
return userID == h.admin return userID == h.admin
} }
func (h *Handler) sendStart(chatID, userID int64, firstName string) { func (h *Handler) sendStart(chatID, userID int64, firstName, tgUsername string) {
h.sendUserMenu(chatID, userID, firstName, tgUsername)
}
func (h *Handler) sendUserMenu(chatID, userID int64, firstName, tgUsername string) {
ctx := context.Background()
_ = h.database.UpsertTelegramUser(ctx, userID, tgUsername, firstName)
h.dismissReplyKeyboard(chatID)
name := firstName name := firstName
if name == "" { if name == "" {
name = "друг" name = "друг"
} }
text := fmt.Sprintf("Привет, %s!\n\nЯ VPN-бот на базе панели Remnawave.", name) days := trialDays(h.cfg)
text := fmt.Sprintf(
"👋 Привет, %s!\n\n"+
"🔐 Trial VPN — %d дн.\n"+
"Нажмите кнопку ниже или /config\n\n"+
"Импорт: V2rayNG, Hiddify, Streisand и др.",
name, days,
)
if h.isAdmin(userID) { if h.isAdmin(userID) {
text += "\n\n/admin — админ-меню\n/admin check — проверка API и подписки" text += "\n\nВы администратор: кнопка «🛠 Админ-панель» или /admin"
} }
msg := tgbotapi.NewMessage(chatID, text) msg := tgbotapi.NewMessage(chatID, text)
if h.isAdmin(userID) { msg.ReplyMarkup = userMenuKeyboard(h.cfg, userID, h.admin)
msg.ReplyMarkup = adminReplyKeyboard()
}
h.send(msg) h.send(msg)
} }
func (h *Handler) dismissReplyKeyboard(chatID int64) {
rm := tgbotapi.NewMessage(chatID, "\u200b")
rm.ReplyMarkup = tgbotapi.NewRemoveKeyboard(true)
rm.DisableNotification = true
if _, err := h.api.Send(rm); err != nil {
log.Printf("remove reply keyboard: %v", err)
}
}
func (h *Handler) isUserConfigButtonText(text string) bool {
return text == userConfigLabel(h.cfg) ||
strings.HasPrefix(text, "🔐 ") ||
strings.HasPrefix(text, "📲 Получить конфиг")
}
func (h *Handler) handleLegacyAdminReply(chatID, userID int64, text string) bool {
switch text {
case "📋 Конфиг панели", "⚙️ Настройки":
h.sendPanelConfig(chatID)
case "🔌 Проверить панель", "🔌 Проверка API":
h.sendPanelCheck(chatID)
case "👤 Создать пользователя", "👤 Новый пользователь":
h.startUserWizard(chatID, userID)
case "📡 Сквады":
h.sendSquadsList(chatID)
case "◀️ Выйти из админки":
h.sendUserMenu(chatID, userID, "", "")
return true
default:
return false
}
return true
}
func (h *Handler) sendAdminMenu(chatID int64) { func (h *Handler) sendAdminMenu(chatID int64) {
text := fmt.Sprintf( text := fmt.Sprintf(
"🛠 *Админ-меню* — %s\n\n"+ "🛠 *Админ-панель* — %s\n\n"+
"Команды:\n"+ "• /admin check — проверка API\n"+
"• /admin — это меню\n"+ "• /admin user — новый пользователь\n"+
"• /admin check — проверка панели, API и подписки\n"+ "• /admin squads — сквады\n"+
"• /admin config — конфиг панели\n\n"+ "• /admin assignназначить сквады\n\n"+
"Или кнопки ниже.", "🏠 Главная — меню пользователя",
escapeMarkdown(h.cfg.RemnawaveName), escapeMarkdown(h.cfg.RemnawaveName),
) )
msg := tgbotapi.NewMessage(chatID, text) msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = "Markdown" msg.ParseMode = "Markdown"
msg.ReplyMarkup = adminInlineKeyboard() msg.ReplyMarkup = adminMenuKeyboard()
h.send(msg) h.send(msg)
} }
func (h *Handler) sendAdminHelp(chatID int64) { func (h *Handler) sendAdminHelp(chatID int64) {
h.sendText(chatID, "Неизвестный аргумент.\n\n/admin — меню\n/admin check — проверка\n/admin config — конфиг") h.sendText(chatID, "Команды:\n/admin — меню\n/admin check\n/admin config\n/admin user\n/admin squads\n/admin assign <логин>")
} }
func (h *Handler) sendPanelConfig(chatID int64) { func (h *Handler) sendPanelConfig(chatID int64) {
subURL := h.cfg.RemnawaveSubscription subURL := h.cfg.RemnawaveSubscription
if subURL == "" { if subURL == "" {
subURL = "не задан" subURL = "не задана (опционально)"
}
caddy := h.cfg.CaddyAuthAPIToken
if caddy == "" {
caddy = "не задан"
} else {
caddy = maskSecret(caddy)
} }
text := fmt.Sprintf( text := fmt.Sprintf(
"⚙️ *%s* (Remnawave)\n\n"+ "⚙️ %s (Remnawave)\n\n"+
"• URL панели: `%s`\n"+ "REMNAWAVE_PANEL_URL:\n%s\n"+
"• URL подписки: `%s`\n"+ "(API: %s/api/... + Bearer REMNAWAVE_API_TOKEN)\n\n"+
"• API token: `%s`\n"+ "REMNAWAVE_SUBSCRIPTION_URL (опц.):\n%s\n\n"+
"• Caddy token: %s\n\n"+ "REMNAWAVE_API_TOKEN: %s\n"+
"Токен API: панель → *Settings → API Tokens*.\n"+ "CADDY_AUTH_API_TOKEN: %s\n\n"+
"Документация: %s", "Токен: Remnawave Settings → API Tokens\n"+
escapeMarkdown(h.cfg.RemnawaveName), "Док: %s",
escapeMarkdown(h.cfg.RemnawaveURL), h.cfg.RemnawaveName,
escapeMarkdown(subURL), h.cfg.RemnawavePanelURL,
escapeMarkdown(maskSecret(h.cfg.RemnawaveToken)), h.cfg.RemnawavePanelURL,
caddyStatus(h.cfg.RemnawaveCaddy), subURL,
docsURL, maskSecret(h.cfg.RemnawaveAPIToken),
caddy,
"https://docs.rw/docs/install/subscription-page/bundled",
) )
msg := tgbotapi.NewMessage(chatID, text) msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = "Markdown" msg.ReplyMarkup = adminContextKeyboard()
msg.ReplyMarkup = adminInlineKeyboard()
h.send(msg) h.send(msg)
} }
@@ -202,48 +307,12 @@ func (h *Handler) sendPanelCheck(chatID int64) {
defer cancel() defer cancel()
report := h.panel.FullCheck(ctx, h.cfg.RemnawaveSubscription) report := h.panel.FullCheck(ctx, h.cfg.RemnawaveSubscription)
text := remnawave.FormatReport( text := remnawave.FormatReport(report, h.cfg.RemnawaveName)
report,
escapeMarkdown(h.cfg.RemnawaveName),
escapeMarkdown(h.cfg.RemnawaveURL),
)
msg := tgbotapi.NewMessage(chatID, text) msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = "Markdown" msg.ReplyMarkup = adminContextKeyboard()
msg.ReplyMarkup = adminInlineKeyboard()
if err := h.sendReturnErr(msg); err != nil {
msg.ParseMode = ""
h.send(msg) h.send(msg)
} }
}
func adminInlineKeyboard() tgbotapi.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверить (API+подписка)", "admin:check"),
tgbotapi.NewInlineKeyboardButtonData("📋 Конфиг", "admin:config"),
),
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonURL("📖 Документация", docsURL),
),
)
}
func adminReplyKeyboard() tgbotapi.ReplyKeyboardMarkup {
return tgbotapi.ReplyKeyboardMarkup{
Keyboard: [][]tgbotapi.KeyboardButton{
{
tgbotapi.NewKeyboardButton("🔌 Проверить панель"),
tgbotapi.NewKeyboardButton("📋 Конфиг панели"),
},
{
tgbotapi.NewKeyboardButton("◀️ Выйти из админки"),
},
},
ResizeKeyboard: true,
OneTimeKeyboard: false,
}
}
func (h *Handler) sendText(chatID int64, text string) { func (h *Handler) sendText(chatID int64, text string) {
h.send(tgbotapi.NewMessage(chatID, text)) h.send(tgbotapi.NewMessage(chatID, text))
+108
View File
@@ -0,0 +1,108 @@
package bot
import (
"fmt"
"telegramvpn/internal/config"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const (
cbUserConfig = "user:config"
cbUserHome = "user:home"
cbAdminMenu = "admin:menu"
cbAdminUser = "admin:user"
cbAdminSquads = "admin:squads"
cbAdminCheck = "admin:check"
cbAdminConfig = "admin:config"
)
func trialDays(cfg *config.Config) int {
d := cfg.TrialUserDays
if d <= 0 {
return 1
}
return d
}
func userConfigLabel(cfg *config.Config) string {
return fmt.Sprintf("🔐 Подключить VPN · %d дн.", trialDays(cfg))
}
func userHomeLabel() string {
return "🏠 Главная"
}
func adminPanelLabel() string {
return "🛠 Админ-панель"
}
// userMenuKeyboard — главное меню пользователя (и выход из админки).
func userMenuKeyboard(cfg *config.Config, userID, adminID int64) tgbotapi.InlineKeyboardMarkup {
rows := [][]tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(userConfigLabel(cfg), cbUserConfig),
),
}
if userID == adminID {
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(adminPanelLabel(), cbAdminMenu),
))
}
return tgbotapi.NewInlineKeyboardMarkup(rows...)
}
// configResultKeyboard — после выдачи конфига: ссылка + возврат в меню.
func configResultKeyboard(cfg *config.Config, userID, adminID int64, subscriptionURL string) tgbotapi.InlineKeyboardMarkup {
var rows [][]tgbotapi.InlineKeyboardButton
if subscriptionURL != "" {
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonURL("🔗 Открыть подписку", subscriptionURL),
))
}
rows = append(rows,
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(userConfigLabel(cfg), cbUserConfig),
),
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome),
),
)
if userID == adminID {
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(adminPanelLabel(), cbAdminMenu),
))
}
return tgbotapi.NewInlineKeyboardMarkup(rows...)
}
// adminMenuKeyboard — панель администратора.
func adminMenuKeyboard() tgbotapi.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("👤 Новый пользователь", cbAdminUser),
tgbotapi.NewInlineKeyboardButtonData("📡 Сквады", cbAdminSquads),
),
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("🔌 Проверка API", cbAdminCheck),
tgbotapi.NewInlineKeyboardButtonData("⚙️ Настройки", cbAdminConfig),
),
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonURL("📖 Remnawave Docs", docsURL),
),
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome),
),
)
}
// adminContextKeyboard — под ответами админки (проверка, конфиг и т.д.).
func adminContextKeyboard() tgbotapi.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("🛠 В админ-панель", cbAdminMenu),
tgbotapi.NewInlineKeyboardButtonData(userHomeLabel(), cbUserHome),
),
)
}
+139
View File
@@ -0,0 +1,139 @@
package bot
import (
"context"
"fmt"
"log"
"time"
"telegramvpn/internal/db"
"telegramvpn/internal/remnawave"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (h *Handler) handleUserConfig(chatID, telegramID int64) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
days := h.cfg.TrialUserDays
if days <= 0 {
days = 1
}
existing, err := h.database.GetVPNByTelegramID(ctx, telegramID)
if err != nil {
h.sendText(chatID, "Ошибка базы данных. Попробуйте позже.")
return
}
if existing != nil && existing.ExpireAt != nil && existing.ExpireAt.After(time.Now()) {
link := h.resolveSubscriptionLink(ctx, existing.RemnawaveUsername, telegramID)
h.sendConfigMessage(chatID, telegramID, days, existing.RemnawaveUsername, *existing.ExpireAt, link)
return
}
panelUser, err := h.panel.GetUserByTelegramID(ctx, telegramID)
if err == nil && panelUser != nil && panelUser.ExpireAt.After(time.Now()) {
link := panelUser.SubscriptionURL
if link == "" {
link = h.subscriptionLink(panelUser.ShortUUID)
}
_ = h.saveVPNFromPanel(ctx, telegramID, panelUser)
h.sendConfigMessage(chatID, telegramID, days, panelUser.Username, panelUser.ExpireAt, link)
return
}
username := fmt.Sprintf("u%d", telegramID)
var extPtr *string
if h.cfg.DefaultExternalSquadUUID != "" {
e := h.cfg.DefaultExternalSquadUUID
extPtr = &e
}
ints := h.cfg.DefaultInternalSquadUUIDs
tgID := telegramID
u, err := h.panel.CreateUser(ctx, remnawave.CreateUserInput{
Username: username,
ExpireAt: db.DefaultExpireAt(days),
TelegramID: &tgID,
ExternalSquadUUID: extPtr,
ActiveInternalSquads: ints,
Description: fmt.Sprintf("trial %d day via tgvpn bot", days),
})
if err != nil {
h.sendText(chatID, "Не удалось создать доступ: "+err.Error()+"\n\nПопробуйте позже или напишите администратору.")
return
}
if err := h.saveVPNFromPanel(ctx, telegramID, u); err != nil {
log.Printf("save vpn user: %v", err)
}
link := u.SubscriptionURL
if link == "" {
link = h.subscriptionLink(u.ShortUUID)
}
h.sendConfigMessage(chatID, telegramID, days, u.Username, u.ExpireAt, link)
}
func (h *Handler) saveVPNFromPanel(ctx context.Context, telegramID int64, u *remnawave.PanelUser) error {
if u == nil {
return nil
}
var ext *string
if h.cfg.DefaultExternalSquadUUID != "" {
e := h.cfg.DefaultExternalSquadUUID
ext = &e
}
return h.database.SaveVPNUser(ctx, db.VPNUser{
TelegramID: &telegramID,
RemnawaveUUID: u.UUID,
RemnawaveUsername: u.Username,
ExternalSquadUUID: ext,
InternalSquadUUIDs: h.cfg.DefaultInternalSquadUUIDs,
ExpireAt: &u.ExpireAt,
})
}
func (h *Handler) resolveSubscriptionLink(ctx context.Context, username string, telegramID int64) string {
if u, err := h.panel.GetUserByUsername(ctx, username); err == nil && u != nil {
if u.SubscriptionURL != "" {
return u.SubscriptionURL
}
return h.subscriptionLink(u.ShortUUID)
}
if u, err := h.panel.GetUserByTelegramID(ctx, telegramID); err == nil && u != nil {
if u.SubscriptionURL != "" {
return u.SubscriptionURL
}
return h.subscriptionLink(u.ShortUUID)
}
return ""
}
func (h *Handler) subscriptionLink(shortUUID string) string {
if shortUUID != "" && h.cfg.RemnawaveSubscription != "" {
return h.cfg.RemnawaveSubscription + "/" + shortUUID
}
return ""
}
func (h *Handler) sendConfigMessage(chatID, telegramID int64, days int, username string, expireAt time.Time, link string) {
text := fmt.Sprintf(
"✅ VPN готов\n\n"+
"⏱ Срок: %d дн. (до %s)\n"+
"👤 Логин: %s\n",
days,
expireAt.Local().Format("02.01.2006 15:04"),
username,
)
if link != "" {
text += "\n🔗 Ссылка подписки — кнопка ниже или:\n" + link
} else {
text += "\n\n⚠️ Ссылка не настроена — REMNAWAVE_SUBSCRIPTION_URL в .env"
}
msg := tgbotapi.NewMessage(chatID, text)
msg.ReplyMarkup = configResultKeyboard(h.cfg, telegramID, h.admin, link)
h.send(msg)
}
+58 -11
View File
@@ -7,15 +7,23 @@ import (
"strings" "strings"
) )
// См. официальную схему env: https://docs.rw/docs/install/subscription-page/bundled
// REMNAWAVE_PANEL_URL + REMNAWAVE_API_TOKEN (+ опционально CADDY_AUTH_API_TOKEN)
type Config struct { type Config struct {
BotToken string BotToken string
BotDebug bool BotDebug bool
TelegramAdminID int64 TelegramAdminID int64
RemnawaveName string RemnawaveName string
RemnawaveURL string RemnawavePanelURL string
RemnawaveToken string RemnawaveAPIToken string
RemnawaveCaddy string CaddyAuthAPIToken string
RemnawaveSubscription string RemnawaveSubscription string
DatabaseURL string
DefaultUserDays int
TrialUserDays int
DefaultExternalSquadUUID string
DefaultInternalSquadUUIDs []string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -31,15 +39,15 @@ func Load() (*Config, error) {
panelURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_URL")), "/") panelURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_URL")), "/")
if panelURL == "" { if panelURL == "" {
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL не задан") return nil, fmt.Errorf("REMNAWAVE_PANEL_URL не задан (URL панели, см. https://docs.rw/docs/install/subscription-page/bundled)")
} }
if !strings.HasPrefix(panelURL, "http://") && !strings.HasPrefix(panelURL, "https://") { if !strings.HasPrefix(panelURL, "http://") && !strings.HasPrefix(panelURL, "https://") {
return nil, fmt.Errorf("REMNAWAVE_PANEL_URL должен начинаться с http:// или https://") return nil, fmt.Errorf("REMNAWAVE_PANEL_URL должен быть с http:// или https:// (как в документации Remnawave)")
} }
panelToken := strings.TrimSpace(os.Getenv("REMNAWAVE_API_TOKEN")) apiToken := strings.TrimSpace(os.Getenv("REMNAWAVE_API_TOKEN"))
if panelToken == "" { if apiToken == "" {
return nil, fmt.Errorf("REMNAWAVE_API_TOKEN не задан (создайте в панели: Settings → API Tokens)") return nil, fmt.Errorf("REMNAWAVE_API_TOKEN не задан (Remnawave Settings → API Tokens)")
} }
name := strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_NAME")) name := strings.TrimSpace(os.Getenv("REMNAWAVE_PANEL_NAME"))
@@ -47,19 +55,58 @@ func Load() (*Config, error) {
name = "Панель 1" name = "Панель 1"
} }
caddy := strings.TrimSpace(os.Getenv("CADDY_AUTH_API_TOKEN"))
if caddy == "" {
caddy = strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")) // устаревшее имя
}
subURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_SUBSCRIPTION_URL")), "/") subURL := strings.TrimRight(strings.TrimSpace(os.Getenv("REMNAWAVE_SUBSCRIPTION_URL")), "/")
if subURL != "" && !strings.HasPrefix(subURL, "http://") && !strings.HasPrefix(subURL, "https://") { if subURL != "" && !strings.HasPrefix(subURL, "http://") && !strings.HasPrefix(subURL, "https://") {
return nil, fmt.Errorf("REMNAWAVE_SUBSCRIPTION_URL должен начинаться с http:// или https://") return nil, fmt.Errorf("REMNAWAVE_SUBSCRIPTION_URL должен начинаться с http:// или https://")
} }
dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL не задан")
}
days := 1
if v := strings.TrimSpace(os.Getenv("DEFAULT_USER_DAYS")); v != "" {
if d, err := strconv.Atoi(v); err == nil && d > 0 {
days = d
}
}
trialDays := days
if v := strings.TrimSpace(os.Getenv("TRIAL_USER_DAYS")); v != "" {
if d, err := strconv.Atoi(v); err == nil && d > 0 {
trialDays = d
}
}
var internalSquads []string
if v := strings.TrimSpace(os.Getenv("DEFAULT_INTERNAL_SQUAD_UUIDS")); v != "" {
for _, part := range strings.Split(v, ",") {
part = strings.TrimSpace(part)
if part != "" {
internalSquads = append(internalSquads, part)
}
}
}
return &Config{ return &Config{
BotToken: token, BotToken: token,
BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"), BotDebug: strings.EqualFold(strings.TrimSpace(os.Getenv("BOT_DEBUG")), "true"),
TelegramAdminID: adminID, TelegramAdminID: adminID,
RemnawaveName: name, RemnawaveName: name,
RemnawaveURL: panelURL, RemnawavePanelURL: panelURL,
RemnawaveToken: panelToken, RemnawaveAPIToken: apiToken,
RemnawaveCaddy: strings.TrimSpace(os.Getenv("REMNAWAVE_CADDY_TOKEN")), CaddyAuthAPIToken: caddy,
RemnawaveSubscription: subURL, RemnawaveSubscription: subURL,
DatabaseURL: dbURL,
DefaultUserDays: days,
TrialUserDays: trialDays,
DefaultExternalSquadUUID: strings.TrimSpace(os.Getenv("DEFAULT_EXTERNAL_SQUAD_UUID")),
DefaultInternalSquadUUIDs: internalSquads,
}, nil }, nil
} }
+65
View File
@@ -0,0 +1,65 @@
package db
import (
"context"
"embed"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type DB struct {
pool *pgxpool.Pool
}
func Connect(ctx context.Context, databaseURL string) (*DB, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
cfg.MaxConns = 10
cfg.MinConns = 1
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("connect postgres: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
d := &DB{pool: pool}
if err := d.migrate(ctx); err != nil {
pool.Close()
return nil, err
}
return d, nil
}
func (d *DB) Close() {
d.pool.Close()
}
func (d *DB) migrate(ctx context.Context) error {
data, err := migrationsFS.ReadFile("migrations/001_init.sql")
if err != nil {
return fmt.Errorf("read migration: %w", err)
}
_, err = d.pool.Exec(ctx, string(data))
if err != nil {
return fmt.Errorf("apply migration: %w", err)
}
return nil
}
func (d *DB) Pool() *pgxpool.Pool {
return d.pool
}
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS telegram_users (
id BIGSERIAL PRIMARY KEY,
telegram_id BIGINT NOT NULL UNIQUE,
username TEXT,
first_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS vpn_users (
id BIGSERIAL PRIMARY KEY,
telegram_id BIGINT,
remnawave_uuid UUID NOT NULL UNIQUE,
remnawave_username VARCHAR(36) NOT NULL,
external_squad_uuid UUID,
internal_squad_uuids UUID[] NOT NULL DEFAULT '{}',
expire_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vpn_users_telegram ON vpn_users(telegram_id);
CREATE INDEX IF NOT EXISTS idx_vpn_users_username ON vpn_users(remnawave_username);
CREATE TABLE IF NOT EXISTS admin_wizard (
admin_telegram_id BIGINT PRIMARY KEY,
step TEXT NOT NULL,
data JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+99
View File
@@ -0,0 +1,99 @@
package db
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
type VPNUser struct {
ID int64
TelegramID *int64
RemnawaveUUID string
RemnawaveUsername string
ExternalSquadUUID *string
InternalSquadUUIDs []string
ExpireAt *time.Time
}
func (d *DB) UpsertTelegramUser(ctx context.Context, telegramID int64, username, firstName string) error {
_, err := d.pool.Exec(ctx, `
INSERT INTO telegram_users (telegram_id, username, first_name)
VALUES ($1, $2, $3)
ON CONFLICT (telegram_id) DO UPDATE SET
username = EXCLUDED.username,
first_name = EXCLUDED.first_name`,
telegramID, nullStr(username), nullStr(firstName))
return err
}
func (d *DB) SaveVPNUser(ctx context.Context, u VPNUser) error {
_, err := d.pool.Exec(ctx, `
INSERT INTO vpn_users (
telegram_id, remnawave_uuid, remnawave_username,
external_squad_uuid, internal_squad_uuids, expire_at
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (remnawave_uuid) DO UPDATE SET
telegram_id = EXCLUDED.telegram_id,
remnawave_username = EXCLUDED.remnawave_username,
external_squad_uuid = EXCLUDED.external_squad_uuid,
internal_squad_uuids = EXCLUDED.internal_squad_uuids,
expire_at = EXCLUDED.expire_at,
updated_at = NOW()`,
u.TelegramID, u.RemnawaveUUID, u.RemnawaveUsername,
u.ExternalSquadUUID, u.InternalSquadUUIDs, u.ExpireAt)
return err
}
func (d *DB) GetVPNByTelegramID(ctx context.Context, telegramID int64) (*VPNUser, error) {
row := d.pool.QueryRow(ctx, `
SELECT id, telegram_id, remnawave_uuid::text, remnawave_username,
external_squad_uuid::text, internal_squad_uuids::text[], expire_at
FROM vpn_users
WHERE telegram_id = $1
ORDER BY expire_at DESC NULLS LAST
LIMIT 1`, telegramID)
var u VPNUser
var ext *string
var internal []string
err := row.Scan(&u.ID, &u.TelegramID, &u.RemnawaveUUID, &u.RemnawaveUsername, &ext, &internal, &u.ExpireAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
u.ExternalSquadUUID = ext
u.InternalSquadUUIDs = internal
return &u, nil
}
func (d *DB) GetVPNByUsername(ctx context.Context, username string) (*VPNUser, error) {
row := d.pool.QueryRow(ctx, `
SELECT id, telegram_id, remnawave_uuid::text, remnawave_username,
external_squad_uuid::text, internal_squad_uuids::text[], expire_at
FROM vpn_users WHERE remnawave_username = $1`, username)
var u VPNUser
var ext *string
var internal []string
err := row.Scan(&u.ID, &u.TelegramID, &u.RemnawaveUUID, &u.RemnawaveUsername, &ext, &internal, &u.ExpireAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
u.ExternalSquadUUID = ext
u.InternalSquadUUIDs = internal
return &u, nil
}
func nullStr(s string) *string {
if s == "" {
return nil
}
return &s
}
+123
View File
@@ -0,0 +1,123 @@
package db
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
type WizardData map[string]any
type AdminWizard struct {
AdminID int64
Step string
Data WizardData
}
func (d *DB) GetWizard(ctx context.Context, adminID int64) (*AdminWizard, error) {
row := d.pool.QueryRow(ctx, `
SELECT admin_telegram_id, step, data
FROM admin_wizard WHERE admin_telegram_id = $1`, adminID)
var w AdminWizard
var raw []byte
if err := row.Scan(&w.AdminID, &w.Step, &raw); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &w.Data)
}
if w.Data == nil {
w.Data = WizardData{}
}
return &w, nil
}
func (d *DB) SetWizard(ctx context.Context, adminID int64, step string, data WizardData) error {
raw, _ := json.Marshal(data)
_, err := d.pool.Exec(ctx, `
INSERT INTO admin_wizard (admin_telegram_id, step, data, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (admin_telegram_id) DO UPDATE SET
step = EXCLUDED.step,
data = EXCLUDED.data,
updated_at = NOW()`,
adminID, step, raw)
return err
}
func (d *DB) ClearWizard(ctx context.Context, adminID int64) error {
_, err := d.pool.Exec(ctx, `DELETE FROM admin_wizard WHERE admin_telegram_id = $1`, adminID)
return err
}
func (w WizardData) String(key string) string {
v, _ := w[key].(string)
return v
}
func (w WizardData) Int(key string) int {
switch v := w[key].(type) {
case float64:
return int(v)
case int:
return v
default:
return 0
}
}
func (w WizardData) StringSlice(key string) []string {
raw, ok := w[key].([]any)
if !ok {
if ss, ok := w[key].([]string); ok {
return ss
}
return nil
}
out := make([]string, 0, len(raw))
for _, x := range raw {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
func (w WizardData) Set(key string, val any) {
w[key] = val
}
func (w WizardData) ToggleUUID(key, uuid string) {
cur := w.StringSlice(key)
for i, id := range cur {
if id == uuid {
cur = append(cur[:i], cur[i+1:]...)
w[key] = cur
return
}
}
w[key] = append(cur, uuid)
}
const (
StepIdle = ""
StepAwaitUsername = "await_username"
StepAwaitDays = "await_days"
StepPickExternalSquad = "pick_external"
StepPickInternalSquads = "pick_internal"
StepConfirm = "confirm"
)
func DefaultExpireAt(days int) time.Time {
if days <= 0 {
days = 30
}
return time.Now().UTC().AddDate(0, 0, days)
}
+53 -8
View File
@@ -1,6 +1,7 @@
package remnawave package remnawave
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -10,24 +11,32 @@ import (
"time" "time"
) )
// Client обращается к Remnawave Panel API:
// - Base: REMNAWAVE_PANEL_URL (например https://panel.example.com)
// - Auth: Authorization: Bearer REMNAWAVE_API_TOKEN
// - Опционально: X-Api-Key: CADDY_AUTH_API_TOKEN
// Документация: https://docs.rw/docs/install/subscription-page/bundled
type Client struct { type Client struct {
baseURL string panelURL string
token string token string
caddyToken string caddyToken string
http *http.Client http *http.Client
} }
func NewClient(baseURL, apiToken, caddyToken string) *Client { func NewClient(panelURL, apiToken, caddyAuthToken string) *Client {
return &Client{ return &Client{
baseURL: strings.TrimRight(baseURL, "/"), panelURL: strings.TrimRight(panelURL, "/"),
token: apiToken, token: apiToken,
caddyToken: caddyToken, caddyToken: caddyAuthToken,
http: &http.Client{ http: &http.Client{
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
}, },
} }
} }
func (c *Client) PanelURL() string { return c.panelURL }
func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (int, error) { func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (int, error) {
resp, body, err := c.get(ctx, path) resp, body, err := c.get(ctx, path)
if err != nil { if err != nil {
@@ -40,13 +49,41 @@ func (c *Client) countFromEndpoint(ctx context.Context, path, arrayKey string) (
} }
func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte, error) { func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) return c.doRequest(ctx, http.MethodGet, c.panelURL+path, nil, true)
}
func (c *Client) getPublic(ctx context.Context, path string) (*http.Response, []byte, error) {
return c.doRequest(ctx, http.MethodGet, c.panelURL+path, nil, false)
}
func (c *Client) post(ctx context.Context, path string, body any) (*http.Response, []byte, error) {
return c.doRequest(ctx, http.MethodPost, c.panelURL+path, body, true)
}
func (c *Client) patch(ctx context.Context, path string, body any) (*http.Response, []byte, error) {
return c.doRequest(ctx, http.MethodPatch, c.panelURL+path, body, true)
}
func (c *Client) doRequest(ctx context.Context, method, url string, body any, withBearer bool) (*http.Response, []byte, error) {
var bodyReader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, nil, err
}
bodyReader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if withBearer {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if c.caddyToken != "" { if c.caddyToken != "" {
req.Header.Set("X-Api-Key", c.caddyToken) req.Header.Set("X-Api-Key", c.caddyToken)
} }
@@ -57,11 +94,19 @@ func (c *Client) get(ctx context.Context, path string) (*http.Response, []byte,
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil { if err != nil {
return resp, nil, err return resp, nil, err
} }
return resp, body, nil return resp, respBody, nil
}
func apiError(status int, body []byte) error {
msg := trimBody(body, 300)
if msg == "" {
return fmt.Errorf("HTTP %d", status)
}
return fmt.Errorf("HTTP %d: %s", status, msg)
} }
func parseCount(body []byte, arrayKey string) int { func parseCount(body []byte, arrayKey string) int {
+99 -33
View File
@@ -10,39 +10,54 @@ import (
type CheckItem struct { type CheckItem struct {
Name string Name string
OK bool OK bool
Skipped bool
Status int Status int
Detail string Detail string
} }
type HealthReport struct { type HealthReport struct {
PanelName string
PanelURL string PanelURL string
Checks []CheckItem Checks []CheckItem
Users int Users int
Nodes int Nodes int
AllOK bool AllOK bool
Hint string
} }
func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthReport { func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthReport {
report := HealthReport{ report := HealthReport{
PanelName: "", PanelURL: c.panelURL,
PanelURL: c.baseURL,
} }
probes := []struct { probes := []struct {
name string name string
path string path string
isWeb bool
}{ }{
{"Панель (веб)", "/"}, {"Панель (веб)", "/", true},
{"API (статистика)", "/api/system/stats/recap"}, {"API (статистика)", "/api/system/stats/recap", false},
{"API (пользователи)", "/api/users"}, {"API (пользователи)", "/api/users", false},
{"API (ноды)", "/api/nodes"}, {"API (ноды)", "/api/nodes", false},
{"Подписка (настройки)", "/api/subscription-settings"}, {"Подписка (настройки)", "/api/subscription-settings", false},
{"Подписка (API список)", "/api/subscriptions"}, {"Подписка (API список)", "/api/subscriptions", false},
} }
apiFailures := 0
webOK := false
for _, p := range probes { for _, p := range probes {
item := c.probe(ctx, p.name, p.path) var item CheckItem
if p.isWeb {
item = c.probeWeb(ctx, p.name, p.path)
if item.OK {
webOK = true
}
} else {
item = c.probeAPI(ctx, p.name, p.path)
if !item.OK {
apiFailures++
}
}
report.Checks = append(report.Checks, item) report.Checks = append(report.Checks, item)
if p.path == "/api/users" && item.OK { if p.path == "/api/users" && item.OK {
@@ -63,15 +78,22 @@ func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthRe
} else { } else {
report.Checks = append(report.Checks, CheckItem{ report.Checks = append(report.Checks, CheckItem{
Name: "Страница подписки", Name: "Страница подписки",
OK: false, OK: true,
Status: 0, Skipped: true,
Detail: "не задана (REMNAWAVE_SUBSCRIPTION_URL)", Detail: "опционально, не задана",
}) })
} }
if webOK && apiFailures >= 4 {
report.Hint = "Веб открывается, но /api/* недоступен (502). " +
"По документации Remnawave API вызывается на REMNAWAVE_PANEL_URL (https://panel.example.com/api/...), " +
"а не на домене sub.*. Укажите URL админ-панели и токен REMNAWAVE_API_TOKEN. " +
"Док: https://docs.rw/docs/install/subscription-page/bundled"
}
report.AllOK = true report.AllOK = true
for _, ch := range report.Checks { for _, ch := range report.Checks {
if !ch.OK { if !ch.OK && !ch.Skipped {
report.AllOK = false report.AllOK = false
break break
} }
@@ -79,14 +101,27 @@ func (c *Client) FullCheck(ctx context.Context, subscriptionURL string) HealthRe
return report return report
} }
func (c *Client) probe(ctx context.Context, name, path string) CheckItem { func (c *Client) probeWeb(ctx context.Context, name, path string) CheckItem {
item := CheckItem{Name: name} item := CheckItem{Name: name}
resp, body, err := c.getPublic(ctx, path)
if err != nil {
item.Detail = err.Error()
return item
}
return finishProbe(&item, resp, body)
}
func (c *Client) probeAPI(ctx context.Context, name, path string) CheckItem {
item := CheckItem{Name: name}
resp, body, err := c.get(ctx, path) resp, body, err := c.get(ctx, path)
if err != nil { if err != nil {
item.Detail = err.Error() item.Detail = err.Error()
return item return item
} }
return finishProbe(&item, resp, body)
}
func finishProbe(item *CheckItem, resp *http.Response, body []byte) CheckItem {
item.Status = resp.StatusCode item.Status = resp.StatusCode
switch resp.StatusCode { switch resp.StatusCode {
@@ -94,11 +129,31 @@ func (c *Client) probe(ctx context.Context, name, path string) CheckItem {
item.OK = true item.OK = true
item.Detail = "OK" item.Detail = "OK"
case http.StatusUnauthorized, http.StatusForbidden: case http.StatusUnauthorized, http.StatusForbidden:
item.Detail = fmt.Sprintf("HTTP %d — неверный токен или нет прав", resp.StatusCode) item.Detail = "неверный REMNAWAVE_API_TOKEN или CADDY_AUTH_API_TOKEN"
default: default:
item.Detail = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, trimBody(body, 120)) item.Detail = statusHint(resp.StatusCode)
if item.Detail == "" {
if s := trimBody(body, 80); s != "" {
item.Detail = s
} else {
item.Detail = http.StatusText(resp.StatusCode)
}
}
}
return *item
}
func statusHint(code int) string {
switch code {
case http.StatusBadGateway:
return "Bad Gateway — прокси не достучался до API панели"
case http.StatusServiceUnavailable:
return "Service Unavailable — бэкенд панели не запущен"
case http.StatusNotFound:
return "Not Found — путь /api не проксируется на панель"
default:
return ""
} }
return item
} }
func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem { func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem {
@@ -124,28 +179,35 @@ func (c *Client) probePublic(ctx context.Context, name, url string) CheckItem {
item.Status = resp.StatusCode item.Status = resp.StatusCode
if resp.StatusCode >= 200 && resp.StatusCode < 400 { if resp.StatusCode >= 200 && resp.StatusCode < 400 {
item.OK = true item.OK = true
item.Detail = fmt.Sprintf("OK (HTTP %d)", resp.StatusCode) item.Detail = "OK"
} else { } else {
item.Detail = fmt.Sprintf("HTTP %d", resp.StatusCode) item.Detail = statusHint(resp.StatusCode)
if item.Detail == "" {
item.Detail = http.StatusText(resp.StatusCode)
}
} }
return item return item
} }
func FormatReport(r HealthReport, panelName, panelURL string) string { // FormatReport — обычный текст (без Markdown), чтобы URL и имена env отображались корректно.
func FormatReport(r HealthReport, panelName string) string {
var b strings.Builder var b strings.Builder
if panelName != "" {
b.WriteString(fmt.Sprintf("Панель: *%s*\nURL: `%s`\n\n", panelName, panelURL))
}
icon := func(ok bool) string { if panelName != "" {
if ok { b.WriteString(fmt.Sprintf("Панель: %s\n", panelName))
return "✅"
}
return "❌"
} }
b.WriteString(fmt.Sprintf("REMNAWAVE_PANEL_URL: %s\n", r.PanelURL))
b.WriteString(fmt.Sprintf("API: %s/api/...\n\n", r.PanelURL))
for _, ch := range r.Checks { for _, ch := range r.Checks {
line := fmt.Sprintf("%s *%s*", icon(ch.OK), ch.Name) mark := "❌"
switch {
case ch.Skipped:
mark = "○"
case ch.OK:
mark = "✅"
}
line := fmt.Sprintf("%s %s", mark, ch.Name)
if ch.Status > 0 { if ch.Status > 0 {
line += fmt.Sprintf(" — HTTP %d", ch.Status) line += fmt.Sprintf(" — HTTP %d", ch.Status)
} }
@@ -156,13 +218,17 @@ func FormatReport(r HealthReport, panelName, panelURL string) string {
} }
if r.Users > 0 || r.Nodes > 0 { if r.Users > 0 || r.Nodes > 0 {
b.WriteString(fmt.Sprintf("\n👥 Пользователей: %d\n📡 Нод: %d", r.Users, r.Nodes)) b.WriteString(fmt.Sprintf("\nПользователей: %d\nНод: %d", r.Users, r.Nodes))
}
if r.Hint != "" {
b.WriteString("\n\n💡 " + r.Hint)
} }
if r.AllOK { if r.AllOK {
b.WriteString("\n\n✅ *Все проверки пройдены*") b.WriteString("\n\nВсе обязательные проверки пройдены.")
} else { } else {
b.WriteString("\n\n⚠️ *Есть ошибки*проверьте токен, URL и страницу подписки") b.WriteString("\n\nЕсть ошибки — см. подсказку выше.")
} }
return b.String() return b.String()
} }
+85
View File
@@ -0,0 +1,85 @@
package remnawave
import (
"context"
"encoding/json"
"net/http"
)
type Squad struct {
UUID string
Name string
}
func (c *Client) ListInternalSquads(ctx context.Context) ([]Squad, error) {
return c.listSquads(ctx, "/api/internal-squads")
}
func (c *Client) ListExternalSquads(ctx context.Context) ([]Squad, error) {
return c.listSquads(ctx, "/api/external-squads")
}
func (c *Client) listSquads(ctx context.Context, path string) ([]Squad, error) {
resp, body, err := c.get(ctx, path)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, apiError(resp.StatusCode, body)
}
return parseSquads(body), nil
}
func parseSquads(body []byte) []Squad {
var wrap map[string]json.RawMessage
if json.Unmarshal(body, &wrap) != nil {
return nil
}
if raw, ok := wrap["response"]; ok {
if list := squadsFromRaw(raw); len(list) > 0 {
return list
}
}
return squadsFromRaw(body)
}
func squadsFromRaw(data []byte) []Squad {
var arr []map[string]json.RawMessage
if json.Unmarshal(data, &arr) == nil {
return squadsFromMaps(arr)
}
var obj map[string]json.RawMessage
if json.Unmarshal(data, &obj) != nil {
return nil
}
for _, key := range []string{"internalSquads", "externalSquads", "squads"} {
if raw, ok := obj[key]; ok {
if list := squadsFromRaw(raw); len(list) > 0 {
return list
}
}
}
for _, v := range obj {
if list := squadsFromRaw(v); len(list) > 0 {
return list
}
}
return nil
}
func squadsFromMaps(items []map[string]json.RawMessage) []Squad {
out := make([]Squad, 0, len(items))
for _, m := range items {
s := Squad{}
if raw, ok := m["uuid"]; ok {
_ = json.Unmarshal(raw, &s.UUID)
}
if raw, ok := m["name"]; ok {
_ = json.Unmarshal(raw, &s.Name)
}
if s.UUID != "" {
out = append(out, s)
}
}
return out
}
+164
View File
@@ -0,0 +1,164 @@
package remnawave
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type CreateUserInput struct {
Username string
ExpireAt time.Time
TelegramID *int64
ExternalSquadUUID *string
ActiveInternalSquads []string
TrafficLimitBytes *int64
Description string
}
type PanelUser struct {
UUID string
Username string
ShortUUID string
Status string
ExpireAt time.Time
SubscriptionURL string
}
func (c *Client) GetUserByUsername(ctx context.Context, username string) (*PanelUser, error) {
path := fmt.Sprintf("/api/users/by-username/%s", username)
resp, body, err := c.get(ctx, path)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, apiError(resp.StatusCode, body)
}
return parsePanelUser(body), nil
}
func (c *Client) GetUserByTelegramID(ctx context.Context, telegramID int64) (*PanelUser, error) {
path := fmt.Sprintf("/api/users/by-telegram-id/%d", telegramID)
resp, body, err := c.get(ctx, path)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, apiError(resp.StatusCode, body)
}
u := parsePanelUser(body)
if u == nil || u.UUID == "" {
return nil, nil
}
return u, nil
}
func (c *Client) CreateUser(ctx context.Context, in CreateUserInput) (*PanelUser, error) {
payload := map[string]any{
"username": in.Username,
"expireAt": in.ExpireAt.UTC().Format(time.RFC3339Nano),
"status": "ACTIVE",
}
if in.TelegramID != nil {
payload["telegramId"] = *in.TelegramID
}
if in.ExternalSquadUUID != nil && *in.ExternalSquadUUID != "" {
payload["externalSquadUuid"] = *in.ExternalSquadUUID
}
if len(in.ActiveInternalSquads) > 0 {
payload["activeInternalSquads"] = in.ActiveInternalSquads
}
if in.TrafficLimitBytes != nil {
payload["trafficLimitBytes"] = *in.TrafficLimitBytes
}
if in.Description != "" {
payload["description"] = in.Description
}
resp, body, err := c.post(ctx, "/api/users", payload)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, apiError(resp.StatusCode, body)
}
return parsePanelUser(body), nil
}
type AssignSquadsInput struct {
UUID string
Username string
ExternalSquadUUID *string
ActiveInternalSquads []string
}
func (c *Client) AssignSquads(ctx context.Context, in AssignSquadsInput) (*PanelUser, error) {
if in.UUID == "" && in.Username == "" {
return nil, fmt.Errorf("нужен uuid или username")
}
payload := map[string]any{}
if in.UUID != "" {
payload["uuid"] = in.UUID
} else {
payload["username"] = in.Username
}
if in.ExternalSquadUUID != nil {
payload["externalSquadUuid"] = in.ExternalSquadUUID
}
if in.ActiveInternalSquads != nil {
payload["activeInternalSquads"] = in.ActiveInternalSquads
}
resp, body, err := c.patch(ctx, "/api/users", payload)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, apiError(resp.StatusCode, body)
}
return parsePanelUser(body), nil
}
func parsePanelUser(body []byte) *PanelUser {
var wrap struct {
Response map[string]json.RawMessage `json:"response"`
}
if json.Unmarshal(body, &wrap) != nil || wrap.Response == nil {
return nil
}
u := &PanelUser{}
if raw, ok := wrap.Response["uuid"]; ok {
_ = json.Unmarshal(raw, &u.UUID)
}
if raw, ok := wrap.Response["username"]; ok {
_ = json.Unmarshal(raw, &u.Username)
}
if raw, ok := wrap.Response["shortUuid"]; ok {
_ = json.Unmarshal(raw, &u.ShortUUID)
}
if raw, ok := wrap.Response["status"]; ok {
_ = json.Unmarshal(raw, &u.Status)
}
if raw, ok := wrap.Response["expireAt"]; ok {
var s string
if json.Unmarshal(raw, &s) == nil {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
u.ExpireAt = t
} else if t, err := time.Parse(time.RFC3339, s); err == nil {
u.ExpireAt = t
}
}
}
if raw, ok := wrap.Response["subscriptionUrl"]; ok {
_ = json.Unmarshal(raw, &u.SubscriptionURL)
}
return u
}
+27 -4
View File
@@ -1,10 +1,15 @@
package main package main
import ( import (
"context"
"log" "log"
"os"
"os/signal"
"syscall"
"telegramvpn/internal/bot" "telegramvpn/internal/bot"
"telegramvpn/internal/config" "telegramvpn/internal/config"
"telegramvpn/internal/db"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@@ -18,22 +23,40 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
database, err := db.Connect(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatal(err)
}
defer database.Close()
api, err := tgbotapi.NewBotAPI(cfg.BotToken) api, err := tgbotapi.NewBotAPI(cfg.BotToken)
if err != nil { if err != nil {
log.Fatalf("не удалось подключиться к Telegram: %v", err) log.Fatalf("не удалось подключиться к Telegram: %v", err)
} }
api.Debug = cfg.BotDebug api.Debug = cfg.BotDebug
log.Printf("бот @%s запущен, админ ID %d, панель %q (%s)", log.Printf("бот @%s запущен, админ ID %d, панель %q, postgres ok",
api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawaveName, cfg.RemnawaveURL) api.Self.UserName, cfg.TelegramAdminID, cfg.RemnawavePanelURL)
handler := bot.NewHandler(cfg, api) handler := bot.NewHandler(cfg, api, database)
handler.RegisterCommands() handler.RegisterCommands()
u := tgbotapi.NewUpdate(0) u := tgbotapi.NewUpdate(0)
u.Timeout = 60 u.Timeout = 60
for update := range api.GetUpdatesChan(u) { for {
select {
case <-ctx.Done():
log.Println("остановка бота…")
return
case update, ok := <-api.GetUpdatesChan(u):
if !ok {
return
}
handler.HandleUpdate(update) handler.HandleUpdate(update)
} }
} }
}