Enhance Telegram bot functionality and configuration options
CI / Backend (build + test) (push) Successful in 1m16s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s

- Added new inline button features to the `/configs` command, allowing users to view their configurations in a single message with active links and a back button.
- Implemented a menu for unlinking Telegram accounts, providing a clearer user experience when managing account connections.
- Updated the `.env.example` file to include a new `Telegram__PublicSiteUrl` setting, enabling a button for accessing the panel's website directly from the bot.
- Enhanced documentation to reflect the new features and configuration options available in the Telegram bot.
This commit is contained in:
Leonid Pershin
2026-07-02 19:39:14 +03:00
parent cf3d8fcad8
commit 5b398f9c59
4 changed files with 165 additions and 52 deletions
+2
View File
@@ -47,6 +47,8 @@ Telegram__AdminTelegramUserIds=123456789
# Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port) — на случай, если Telegram # Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port) — на случай, если Telegram
# заблокирован напрямую с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение. # заблокирован напрямую с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение.
# Telegram__ProxyUrl=socks5://127.0.0.1:39372 # Telegram__ProxyUrl=socks5://127.0.0.1:39372
# Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки не будет.
# Telegram__PublicSiteUrl=https://dashboard.example.com
# ── ASP.NET Core ────────────────────────────────────────────────────────── # ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
@@ -110,6 +110,68 @@ public sealed class PnvBotUpdateHandler(
return; return;
} }
if (data == "menu:configs")
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
return;
}
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
var (configsText, configsKeyboard) = await BuildConfigsMenuAsync(services, cancellationToken);
if (callback.Message is not null)
await botClient.EditMessageText(chatId.Value, callback.Message.Id, configsText, replyMarkup: configsKeyboard, cancellationToken: cancellationToken);
return;
}
if (data == "menu:back")
{
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
if (callback.Message is null)
return;
var identityService = services.GetRequiredService<IIdentityService>();
var linkedUserId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
var (menuText, menuKeyboard) = BuildMainMenu(isLinked: linkedUserId is not null);
await botClient.EditMessageText(chatId.Value, callback.Message.Id, menuText, replyMarkup: menuKeyboard, cancellationToken: cancellationToken);
return;
}
if (data == "menu:unlink")
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
return;
}
var unlinkSender = services.GetRequiredService<ISender>();
var unlinkResult = await unlinkSender.Send(new UnlinkTelegramCommand(), cancellationToken);
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
if (callback.Message is null)
return;
if (!unlinkResult.IsSuccess)
{
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, $"❌ Ошибка: {unlinkResult.Error.Message}",
replyMarkup: BackToMenuKeyboard(), cancellationToken: cancellationToken);
return;
}
var (unlinkedText, unlinkedKeyboard) = BuildMainMenu(isLinked: false);
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, "✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText,
replyMarkup: unlinkedKeyboard, cancellationToken: cancellationToken);
return;
}
var parts = data.Split(':'); var parts = data.Split(':');
if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId)) if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
return; return;
@@ -171,13 +233,21 @@ public sealed class PnvBotUpdateHandler(
return; return;
} }
if (callback.Message is null)
break;
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным // Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением. // блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
var originalText = callback.Message?.Text ?? ""; // Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
var text = $"{Escape(originalText)}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>"; var text = $"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
.Where(row => row.All(b => b.CallbackData != data))
.ToArray();
if (callback.Message is not null) await botClient.EditMessageText(
await botClient.EditMessageText(chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken); chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html,
replyMarkup: remainingRows.Length > 0 ? new InlineKeyboardMarkup(remainingRows) : null,
cancellationToken: cancellationToken);
break; break;
} }
@@ -241,28 +311,38 @@ public sealed class PnvBotUpdateHandler(
return; return;
} }
var (text, keyboard) = await BuildConfigsMenuAsync(services, cancellationToken);
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
}
/// <summary>Список конфигов одним сообщением: строка на конфиг + кнопка «🔗 {Label}» на каждый
/// не отозванный, плюс «🔙 В меню» внизу.</summary>
private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildConfigsMenuAsync(
IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>(); var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken); var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
if (!result.IsSuccess || result.Value.Configs.Count == 0) if (!result.IsSuccess || result.Value.Configs.Count == 0)
{ return ("У вас пока нет конфигов.", BackToMenuKeyboard());
await botClient.SendMessage(chatId, "У вас пока нет конфигов.", cancellationToken: cancellationToken);
return;
}
foreach (var config in result.Value.Configs) var text = "Ваши конфиги:\n" + string.Join('\n', result.Value.Configs.Select(c =>
{ $"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"));
var text = $"• {config.Label ?? config.Location} ({config.Protocol}) — {config.Status}";
// Отозванному конфигу нечего показывать — кнопку не даём. // Отозванному конфигу нечего показывать — кнопку не даём.
InlineKeyboardMarkup? keyboard = config.Status == ConfigStatus.Revoked var rows = result.Value.Configs
? null .Where(c => c.Status != ConfigStatus.Revoked)
: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("🔗 Показать ссылку", $"cfg:link:{config.Id}") }); .Select(c => new[] { InlineKeyboardButton.WithCallbackData($"🔗 {c.Label ?? c.Location}", $"cfg:link:{c.Id}") })
.Append(BackToMenuRow())
.ToArray();
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken); return (text, new InlineKeyboardMarkup(rows));
}
} }
private static InlineKeyboardButton[] BackToMenuRow() => new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") };
private static InlineKeyboardMarkup BackToMenuKeyboard() => new(new[] { BackToMenuRow() });
private async Task HandleUnlinkAsync( private async Task HandleUnlinkAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
{ {
@@ -310,26 +390,40 @@ public sealed class PnvBotUpdateHandler(
} }
} }
private static async Task SendWelcomeAsync( private async Task SendWelcomeAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
{ {
const string text = "Привет! Это бот PnvPanel.\n\n"
+ "/configs — мои конфиги\n"
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.\n"
+ "/unlink — отвязать Telegram\n"
+ "/help — эта справка";
var identityService = services.GetRequiredService<IIdentityService>(); var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken); var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
// Уже привязанным аккаунту предлагать регистрацию незачем. var (text, keyboard) = BuildMainMenu(isLinked: userId is not null);
var keyboard = userId is null
? new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") })
: null;
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken); await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
} }
/// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.</summary>
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked)
{
const string text = "Привет! Это бот PnvPanel.\n\n"
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.";
var rows = new List<InlineKeyboardButton[]>();
if (isLinked)
{
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") });
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink") });
}
else
{
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") });
}
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
rows.Add(new[] { InlineKeyboardButton.WithUrl("🌐 Сайт панели", options.Value.PublicSiteUrl) });
return (text, new InlineKeyboardMarkup(rows));
}
private static async Task HandleRegisterCallbackAsync( private static async Task HandleRegisterCallbackAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId, ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -5,6 +5,8 @@ public sealed class TelegramOptions
public const string SectionName = "Telegram"; public const string SectionName = "Telegram";
public string? BotToken { get; init; } public string? BotToken { get; init; }
/// <summary>Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки нет.</summary>
public string PublicSiteUrl { get; init; } = string.Empty; public string PublicSiteUrl { get; init; } = string.Empty;
/// <summary>Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси.</summary> /// <summary>Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси.</summary>
+38 -23
View File
@@ -6,10 +6,13 @@ Telegram-бот — **второй канал доставки** (presentation-
## Возможности (реализовано) ## Возможности (реализовано)
1. **Мои конфиги**`/configs` присылает по сообщению на конфиг (метка/локация, протокол, статус) с 1. **Мои конфиги**`/configs` (или кнопка «📋 Мои конфиги» из главного меню) присылает **одно**
inline-кнопкой **«🔗 Показать ссылку»**; connection string приходит отдельным сообщением только по сообщение со списком (метка/локация, протокол, статус) и по кнопке `🔗 {Label}` на каждый активный
нажатию (не светится в списке/истории чата без явного действия пользователя). Только текстовая конфиг + кнопкой **«🔙 В меню»** внизу. Нажатие на конфиг **редактирует то же сообщение**: дописывает
ссылка, без QR-картинки — за QR пользователь идёт на сайт. Доступно только привязанному аккаунту. connection string моноширинным блоком (тап = копирование целиком) и убирает именно эту кнопку —
остальные конфиги и «В меню» остаются на месте. Ничего не светится без явного нажатия, отдельных
сообщений не плодится. Только текстовая ссылка, без QR-картинки — за QR пользователь идёт на сайт.
Доступно только привязанному аккаунту.
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте, 2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram. подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`) 3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
@@ -17,13 +20,19 @@ Telegram-бот — **второй канал доставки** (presentation-
«✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию. «✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию.
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка, 4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
принудительный отзыв конфига админом. принудительный отзыв конфига админом.
5. **Отвязка**`/unlink`. 5. **Отвязка**`/unlink` или кнопка «🔓 Отвязать Telegram» из главного меню (редактирует то же
сообщение в подтверждение + меню для непривязанного состояния).
6. **Регистрация прямо из бота** — кнопка «📝 Зарегистрироваться» показывается там, где боту нужен 6. **Регистрация прямо из бота** — кнопка «📝 Зарегистрироваться» показывается там, где боту нужен
привязанный аккаунт, а Telegram ещё не привязан (`/start`, `/help`, `/configs`, запрос passwordless- привязанный аккаунт, а Telegram ещё не привязан (`/start`, `/help`, `/configs`, запрос passwordless-
входа). Логин — `@username` из Telegram; если его нет или он уже занят на сайте — используется входа). Логин — `@username` из Telegram; если его нет или он уже занят на сайте — используется
Telegram id (гарантированно уникален). Пароль генерируется и присылается в чат один раз — сохраните Telegram id (гарантированно уникален). Пароль генерируется и присылается в чат один раз — сохраните
его сразу, при желании логин и пароль можно сменить в Настройках на сайте. Новый аккаунт получает его сразу, при желании логин и пароль можно сменить в Настройках на сайте. Новый аккаунт получает
роль `user` и `IsActivated = false` — активация нужна как для обычной регистрации на сайте. роль `user` и `IsActivated = false` — активация нужна как для обычной регистрации на сайте.
7. **Главное меню**`/start`/`/help` показывают одно сообщение с кнопками вместо текстового списка
команд: привязанному аккаунту — «📋 Мои конфиги» / «🔓 Отвязать Telegram», непривязанному — «📝
Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl`
(пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь
вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`).
**Не реализовано / backlog:** **Не реализовано / backlog:**
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке). - QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
@@ -39,7 +48,7 @@ Telegram-бот — **второй канал доставки** (presentation-
`PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере». `PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере».
- Транспорт — **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на - Транспорт — **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на
этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`) этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`)
содержит только `BotToken`, `BotUsername`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей содержит только `BotToken`, `ProxyUrl`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей
`Mode`/`WebhookUrl`/`WebhookSecret` в коде нет. `Mode`/`WebhookUrl`/`WebhookSecret` в коде нет.
- Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`, - Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`,
как HTTP-запрос — свежие scoped-сервисы на апдейт). как HTTP-запрос — свежие scoped-сервисы на апдейт).
@@ -122,16 +131,19 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
## Флоу 4 — Просмотр конфигов в боте ## Флоу 4 — Просмотр конфигов в боте
1. Привязанный пользователь: `/configs`. 1. Привязанный пользователь: `/configs` (текстовая команда → новое сообщение) или «📋 Мои конфиги» из
главного меню (callback `menu:configs` → редактирует текущее сообщение, см. `BuildConfigsMenuAsync`).
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`. 2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
3. Ответ — отдельное сообщение на каждый конфиг: `• {Label ?? Location} ({Protocol}) — {Status}` + 3. Текст — **одно** сообщение: `Ваши конфиги:` + по строке `• {Label ?? Location} ({Protocol}) — {Status}`.
inline-кнопка «🔗 Показать ссылку» (кроме отозванных — там кнопки нет). Если конфигов нет — Клавиатура — по кнопке `🔗 {Label}` на каждый **не отозванный** конфиг + «🔙 В меню» внизу. Если
«У вас пока нет конфигов.» конфигов нет — «У вас пока нет конфигов.» с той же кнопкой «В меню».
4. Нажатие кнопки → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, что 4. Нажатие `🔗 {Label}` → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же,
эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение** что эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение**
(`EditMessageText`), дописывая ссылку моноширинным блоком (`<code>`, тап = копирование целиком) и (`EditMessageText`): дописывает ссылку моноширинным блоком (`<code>`, тап = копирование целиком) и
убирая кнопку — не плодит отдельное сообщение с сырым URL. Ссылка не раскрывается нигде до явного убирает **именно эту** кнопку из клавиатуры (`InlineKeyboardMarkup.InlineKeyboard`, фильтр по
нажатия. QR-картинки нет — только текст. `CallbackData`) — остальные конфиги и «В меню» остаются кликабельными. Ссылка не раскрывается нигде
до явного нажатия, отдельных сообщений не плодится. QR-картинки нет — только текст.
5. «🔙 В меню» (`menu:back`) — редактирует сообщение обратно в главное меню (`BuildMainMenu`).
## Флоу 5 — Обработка активации админом в боте ## Флоу 5 — Обработка активации админом в боте
@@ -157,13 +169,17 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
| `/start` | Приветствие + справка по командам | нет | | `/start` | Приветствие + справка по командам | нет |
| `/start link_<token>` | Привязка аккаунта по токену | нет | | `/start link_<token>` | Привязка аккаунта по токену | нет |
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да | | `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
| `/configs` | Список конфигов с кнопкой «Показать ссылку» на каждом | да | | `/configs`, «📋 Мои конфиги» (`menu:configs`) | Список конфигов, кнопка `🔗 {Label}` на каждый активный + «🔙 В меню» | да |
| `/unlink` | Отвязать Telegram от аккаунта | да | | `/unlink`, «🔓 Отвязать Telegram» (`menu:unlink`) | Отвязать Telegram от аккаунта | да |
| «🔙 В меню» (`menu:back`) | Вернуться из списка конфигов к главному меню (edit-in-place) | нет |
| «🌐 Сайт панели» | Открыть сайт (`InlineKeyboardButton.WithUrl`, только если задан `Telegram__PublicSiteUrl`) | нет |
| `/help` | Справка (то же сообщение, что `/start`) | нет | | `/help` | Справка (то же сообщение, что `/start`) | нет |
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) | | «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env | | «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env | | `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше.
Любой другой текст → «Не понимаю эту команду. /help — список команд.» Любой другой текст → «Не понимаю эту команду. /help — список команд.»
## Безопасность ## Безопасность
@@ -191,9 +207,8 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
"Telegram": { "Telegram": {
"BotToken": "…", // секрет; пусто = бот не стартует "BotToken": "…", // секрет; пусто = бот не стартует
"ProxyUrl": "socks5://[user:pass@]host:port", // прокси для запросов к Bot API; пусто = без прокси "ProxyUrl": "socks5://[user:pass@]host:port", // прокси для запросов к Bot API; пусто = без прокси
"PublicSiteUrl": "https://dashboard.example.com", // кнопка «🌐 Сайт панели» в меню; пусто = кнопки нет
"AdminTelegramUserIds": "123456789,987654321" // через запятую "AdminTelegramUserIds": "123456789,987654321" // через запятую
// "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код,
// не задавай его — эффекта не будет)
} }
``` ```
@@ -203,10 +218,10 @@ Username бота для deepLink (кнопка «Привязать Telegram»/
`BotUsername`, убрано: опечатка/лишний пробел в env ломали ссылку, а источник истины и так есть в `BotUsername`, убрано: опечатка/лишний пробел в env ломали ссылку, а источник истины и так есть в
самом Telegram). Если `BotToken` пуст или `getMe` не отвечает — `deepLink` в ответах API будет `null`. самом Telegram). Если `BotToken` пуст или `getMe` не отвечает — `deepLink` в ответах API будет `null`.
Переменные окружения — `Telegram__BotToken`, `Telegram__ProxyUrl`, `Telegram__AdminTelegramUserIds` Переменные окружения — `Telegram__BotToken`, `Telegram__ProxyUrl`, `Telegram__PublicSiteUrl`,
(см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют, `Telegram__AdminTelegramUserIds` (см. [`.env.example`](../.env.example)). `AdminTelegramUserIds`
кому слать уведомления о запросах активации — **не** сидируются в БД и не связаны с учёткой авторизует админ-кнопки в боте и определяет, кому слать уведомления о запросах активации — **не**
сид-админа (`AdminSeed:*`), это независимый список. сидируется в БД и не связан с учёткой сид-админа (`AdminSeed:*`), это независимый список.
Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка
интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом — интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом —