Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77cde17ef9 |
Generated
+1
-1
@@ -61,7 +61,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "amnezia-fellow"
|
name = "amnezia-fellow"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"cot",
|
"cot",
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "amnezia-fellow"
|
name = "amnezia-fellow"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync"
|
description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync"
|
||||||
|
|
||||||
@@ -9,8 +9,8 @@ cot = { version = "0.6.0", default-features = false, features = ["sqlite", "json
|
|||||||
schemars = { version = "0.9", features = ["derive"] }
|
schemars = { version = "0.9", features = ["derive"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
openidconnect = "4.0"
|
openidconnect = "4.0"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
tokio = { version = "1", features = ["sync"] }
|
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
miniz_oxide = "0.8"
|
miniz_oxide = "0.8"
|
||||||
qrcode = "0.14"
|
qrcode = "0.14"
|
||||||
|
|||||||
@@ -129,9 +129,12 @@ AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN=<BOT_TOKEN>
|
|||||||
```
|
```
|
||||||
|
|
||||||
In BotFather, create or reuse the same bot and set its Web App URL to
|
In BotFather, create or reuse the same bot and set its Web App URL to
|
||||||
`https://<APP_HOST>/configs`. The bot should reply with the sender's numeric
|
`https://<APP_HOST>/configs`. This app runs the bot in the same process with
|
||||||
Telegram ID, and users must open the bot and send `/start` once before
|
`getUpdates` polling, so do not configure a separate webhook for the same bot.
|
||||||
notifications can be delivered.
|
For browser-based linking, users copy a one-time secret code from the portal and
|
||||||
|
send it to this bot; the app reads the Telegram sender ID from that message.
|
||||||
|
Users must open the bot and send `/start` once before notifications can be
|
||||||
|
delivered.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -140,6 +143,11 @@ The JSON API is session-authenticated:
|
|||||||
- `GET /api/me`
|
- `GET /api/me`
|
||||||
- `GET /api/vpn-clients`
|
- `GET /api/vpn-clients`
|
||||||
- `GET /api/vpn-status`
|
- `GET /api/vpn-status`
|
||||||
|
- `GET /api/telegram-link/status`
|
||||||
|
- `POST /api/telegram-link/webapp`
|
||||||
|
- `POST /api/telegram-link/start`
|
||||||
|
- `POST /api/telegram-link/decline`
|
||||||
|
- `POST /api/telegram-link/unlink`
|
||||||
- `POST /api/vpn-clients`
|
- `POST /api/vpn-clients`
|
||||||
- `POST /api/vpn-clients/{id}/enabled`
|
- `POST /api/vpn-clients/{id}/enabled`
|
||||||
- `DELETE /api/vpn-clients/{id}`
|
- `DELETE /api/vpn-clients/{id}`
|
||||||
|
|||||||
+3
-2
@@ -839,8 +839,9 @@ fn telegram_setup_instructions(config: &AppConfig) -> String {
|
|||||||
AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN={token_value}\n\n\
|
AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN={token_value}\n\n\
|
||||||
BotFather:\n\
|
BotFather:\n\
|
||||||
1. Create or reuse this bot and set the Web App URL to https://<APP_HOST>/configs.\n\
|
1. Create or reuse this bot and set the Web App URL to https://<APP_HOST>/configs.\n\
|
||||||
2. Make the bot reply with the sender numeric Telegram ID.\n\
|
2. Keep webhook disabled: this process receives bot messages with getUpdates polling.\n\
|
||||||
3. Users must open the bot and send /start once before notifications can work."
|
3. Users open the portal, copy the one-time secret code and send it to this bot.\n\
|
||||||
|
4. Users must open the bot and send /start once before notifications can work."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
-32
@@ -151,6 +151,9 @@ struct TelegramLinkStatusResponse {
|
|||||||
bot_url: String,
|
bot_url: String,
|
||||||
linked: bool,
|
linked: bool,
|
||||||
declined: bool,
|
declined: bool,
|
||||||
|
pending: bool,
|
||||||
|
pending_secret: Option<String>,
|
||||||
|
pending_expires_at: Option<i64>,
|
||||||
telegram_id: Option<String>,
|
telegram_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,11 +167,6 @@ struct TelegramWebAppLinkRequest {
|
|||||||
init_data: String,
|
init_data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
|
||||||
struct TelegramManualLinkRequest {
|
|
||||||
telegram_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TELEGRAM_INIT_DATA_MAX_AGE: Duration = Duration::from_secs(86_400);
|
const TELEGRAM_INIT_DATA_MAX_AGE: Duration = Duration::from_secs(86_400);
|
||||||
|
|
||||||
async fn vpn_clients_handler(
|
async fn vpn_clients_handler(
|
||||||
@@ -294,10 +292,9 @@ async fn telegram_link_webapp_handler(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn telegram_link_manual_handler(
|
async fn telegram_link_start_handler(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
Json(request): Json<TelegramManualLinkRequest>,
|
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let mut user = match api_user_record(&session, &db).await? {
|
let mut user = match api_user_record(&session, &db).await? {
|
||||||
Ok(user) => user,
|
Ok(user) => user,
|
||||||
@@ -314,12 +311,15 @@ async fn telegram_link_manual_handler(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let telegram_id = match normalize_manual_telegram_id(&request.telegram_id) {
|
let code = create_unique_telegram_link_code(&db).await?;
|
||||||
Ok(value) => value,
|
let now = telegram::now_unix_seconds();
|
||||||
Err(response) => return Ok(response),
|
user.set_telegram_link_code(&db, Some(&code), Some(now))
|
||||||
};
|
.await
|
||||||
if let Some(response) = link_telegram_id(&db, &mut user, &telegram_id).await? {
|
.map_err(|e| cot::Error::internal(format!("failed to save Telegram link code: {e}")))?;
|
||||||
return Ok(response);
|
if user.telegram_id() == Some("") {
|
||||||
|
user.set_telegram_id(&db, None).await.map_err(|e| {
|
||||||
|
cot::Error::internal(format!("failed to reset Telegram preference: {e}"))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Json(TelegramLinkResponse {
|
Json(TelegramLinkResponse {
|
||||||
@@ -347,7 +347,7 @@ async fn telegram_link_decline_handler(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
user.set_telegram_id(&db, Some(""))
|
user.decline_telegram_link(&db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to save Telegram preference: {e}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to save Telegram preference: {e}")))?;
|
||||||
|
|
||||||
@@ -357,6 +357,26 @@ async fn telegram_link_decline_handler(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn telegram_link_unlink_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let mut user = match api_user_record(&session, &db).await? {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
|
user.clear_telegram_link(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to clear Telegram link: {e}")))?;
|
||||||
|
|
||||||
|
Json(TelegramLinkResponse {
|
||||||
|
status: telegram_status_response(&config, &user),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_vpn_client_handler(
|
async fn create_vpn_client_handler(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -626,6 +646,17 @@ fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStat
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|value| !value.is_empty());
|
.is_some_and(|value| !value.is_empty());
|
||||||
let declined = telegram_id.as_deref() == Some("");
|
let declined = telegram_id.as_deref() == Some("");
|
||||||
|
let pending = user.telegram_link_code().is_some()
|
||||||
|
&& telegram::link_code_is_active(user.telegram_link_code_created_at());
|
||||||
|
let pending_secret = pending
|
||||||
|
.then(|| user.telegram_link_code().map(str::to_owned))
|
||||||
|
.flatten();
|
||||||
|
let pending_expires_at = pending
|
||||||
|
.then(|| {
|
||||||
|
user.telegram_link_code_created_at()
|
||||||
|
.map(telegram::link_code_expires_at)
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
|
||||||
TelegramLinkStatusResponse {
|
TelegramLinkStatusResponse {
|
||||||
enabled: config.telegram_bot_enabled,
|
enabled: config.telegram_bot_enabled,
|
||||||
@@ -633,6 +664,9 @@ fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStat
|
|||||||
bot_username,
|
bot_username,
|
||||||
linked,
|
linked,
|
||||||
declined,
|
declined,
|
||||||
|
pending,
|
||||||
|
pending_secret,
|
||||||
|
pending_expires_at,
|
||||||
telegram_id: linked.then_some(telegram_id).flatten(),
|
telegram_id: linked.then_some(telegram_id).flatten(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,21 +683,21 @@ fn telegram_bot_url(username: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_manual_telegram_id(telegram_id: &str) -> Result<String, cot::response::Response> {
|
async fn create_unique_telegram_link_code(db: &Database) -> cot::Result<String> {
|
||||||
let telegram_id = telegram_id.trim();
|
for _ in 0..8 {
|
||||||
if telegram_id.is_empty()
|
let code = telegram::generate_link_code().map_err(cot::Error::internal)?;
|
||||||
|| telegram_id.len() > 32
|
let existing = User::get_by_telegram_link_code(db, &code)
|
||||||
|| !telegram_id.bytes().all(|byte| byte.is_ascii_digit())
|
.await
|
||||||
{
|
.map_err(|e| {
|
||||||
return Err(json_error_typed(
|
cot::Error::internal(format!("failed to check Telegram link code: {e}"))
|
||||||
cot::http::StatusCode::BAD_REQUEST,
|
})?;
|
||||||
"telegram_id_invalid",
|
if existing.is_none() {
|
||||||
"Telegram ID is invalid",
|
return Ok(code);
|
||||||
"Paste the numeric Telegram ID returned by the bot.",
|
}
|
||||||
"",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(telegram_id.to_owned())
|
Err(cot::Error::internal(
|
||||||
|
"failed to generate unique Telegram link code",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn link_telegram_id(
|
async fn link_telegram_id(
|
||||||
@@ -686,7 +720,7 @@ async fn link_telegram_id(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user.set_telegram_id(db, Some(telegram_id))
|
user.complete_telegram_link(db, telegram_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to save Telegram ID: {e}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to save Telegram ID: {e}")))?;
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -798,15 +832,20 @@ impl App for ApiApp {
|
|||||||
"api_telegram_link_webapp",
|
"api_telegram_link_webapp",
|
||||||
),
|
),
|
||||||
Route::with_api_handler_and_name(
|
Route::with_api_handler_and_name(
|
||||||
"/telegram-link/manual",
|
"/telegram-link/start",
|
||||||
api_post(telegram_link_manual_handler),
|
api_post(telegram_link_start_handler),
|
||||||
"api_telegram_link_manual",
|
"api_telegram_link_start",
|
||||||
),
|
),
|
||||||
Route::with_api_handler_and_name(
|
Route::with_api_handler_and_name(
|
||||||
"/telegram-link/decline",
|
"/telegram-link/decline",
|
||||||
api_post(telegram_link_decline_handler),
|
api_post(telegram_link_decline_handler),
|
||||||
"api_telegram_link_decline",
|
"api_telegram_link_decline",
|
||||||
),
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/telegram-link/unlink",
|
||||||
|
api_post(telegram_link_unlink_handler),
|
||||||
|
"api_telegram_link_unlink",
|
||||||
|
),
|
||||||
Route::with_api_handler_and_name(
|
Route::with_api_handler_and_name(
|
||||||
"/vpn-clients/sync",
|
"/vpn-clients/sync",
|
||||||
api_post(sync_vpn_clients_handler),
|
api_post(sync_vpn_clients_handler),
|
||||||
|
|||||||
+20
-7
@@ -162,20 +162,33 @@ translations! {
|
|||||||
notice_detail: "Detail" , "Детали";
|
notice_detail: "Detail" , "Детали";
|
||||||
telegram_link_title: "Connect Telegram" , "Подключить Telegram";
|
telegram_link_title: "Connect Telegram" , "Подключить Telegram";
|
||||||
telegram_webapp_message: "Save this Telegram account for VPN notifications and bot control." , "Сохранить этот Telegram-аккаунт для уведомлений и управления через бота.";
|
telegram_webapp_message: "Save this Telegram account for VPN notifications and bot control." , "Сохранить этот Telegram-аккаунт для уведомлений и управления через бота.";
|
||||||
telegram_manual_message: "You can connect Telegram once and manage VPN keys from the bot." , "Можно один раз подключить Telegram и управлять VPN-ключами через бота.";
|
telegram_manual_message: "Connect Telegram by sending a one-time secret code to the bot." , "Подключите Telegram, отправив одноразовый секретный код боту.";
|
||||||
telegram_connect: "Connect" , "Подключить";
|
telegram_connect: "Connect" , "Подключить";
|
||||||
telegram_skip: "Do not ask again" , "Больше не предлагать";
|
telegram_skip: "Do not ask again" , "Больше не предлагать";
|
||||||
telegram_guide_title: "How to get your Telegram ID" , "Как узнать Telegram ID";
|
telegram_guide_title: "Send this code to the bot" , "Отправьте этот код боту";
|
||||||
telegram_open_bot: "Open bot" , "Открыть бота";
|
telegram_open_bot: "Open bot" , "Открыть бота";
|
||||||
telegram_guide_start: "Open the bot and send /start once." , "Откройте бота и один раз отправьте /start.";
|
telegram_guide_start: "Open the bot and send /start once if you have not done it before." , "Откройте бота и один раз отправьте /start, если ещё не делали этого.";
|
||||||
telegram_guide_get_id: "Ask the bot for your ID; it will reply with only the number." , "Напишите боту, он ответит только номером вашего ID.";
|
telegram_guide_get_id: "Send the secret code below to the bot." , "Отправьте боту секретный код ниже.";
|
||||||
telegram_guide_paste: "Paste that number here and save it." , "Вставьте этот номер сюда и сохраните.";
|
telegram_guide_paste: "Return here and refresh the status." , "Вернитесь сюда и обновите статус.";
|
||||||
telegram_id_label: "Telegram ID" , "Telegram ID";
|
telegram_secret_label: "Secret code" , "Секретный код";
|
||||||
telegram_id_placeholder: "Only digits" , "Только цифры";
|
|
||||||
telegram_save: "Save Telegram" , "Сохранить Telegram";
|
telegram_save: "Save Telegram" , "Сохранить Telegram";
|
||||||
telegram_saved: "Telegram connected." , "Telegram подключён.";
|
telegram_saved: "Telegram connected." , "Telegram подключён.";
|
||||||
telegram_declined: "Telegram prompt disabled." , "Предложение Telegram отключено.";
|
telegram_declined: "Telegram prompt disabled." , "Предложение Telegram отключено.";
|
||||||
telegram_bot_unconfigured: "bot is not configured" , "бот не настроен";
|
telegram_bot_unconfigured: "bot is not configured" , "бот не настроен";
|
||||||
|
telegram_status_connected: "Telegram connected" , "Telegram подключён";
|
||||||
|
telegram_status_pending: "Telegram code is waiting" , "Код Telegram ожидает";
|
||||||
|
telegram_status_empty: "Telegram is not connected" , "Telegram не подключён";
|
||||||
|
telegram_status_disabled: "Telegram is disabled" , "Telegram отключён";
|
||||||
|
telegram_change: "Change" , "Сменить";
|
||||||
|
telegram_delete: "Remove" , "Удалить";
|
||||||
|
telegram_delete_confirm: "Remove Telegram from this account?" , "Удалить Telegram из этого аккаунта?";
|
||||||
|
telegram_unlinked: "Telegram removed." , "Telegram удалён.";
|
||||||
|
telegram_copy_secret: "Copy code" , "Копировать код";
|
||||||
|
telegram_secret_copied: "Secret code copied." , "Секретный код скопирован.";
|
||||||
|
telegram_refresh_status: "Check status" , "Проверить статус";
|
||||||
|
telegram_pending_message: "Waiting for a message with the secret code." , "Жду сообщение с секретным кодом.";
|
||||||
|
telegram_connected_message: "This account is ready for Telegram bot actions." , "Этот аккаунт готов к действиям через Telegram-бота.";
|
||||||
|
telegram_not_connected_message: "Telegram is not connected yet." , "Telegram пока не подключён.";
|
||||||
|
|
||||||
// VPN server management
|
// VPN server management
|
||||||
servers_empty: "No registered servers." , "Нет зарегистрированных серверов.";
|
servers_empty: "No registered servers." , "Нет зарегистрированных серверов.";
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ fn main() -> impl Project {
|
|||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
|
||||||
tracing::info!("loaded config: {:?}", app_config);
|
tracing::info!("loaded config: {:?}", app_config);
|
||||||
|
telegram::spawn_bot_worker(Arc::clone(&app_config));
|
||||||
|
|
||||||
AmneziaFellowProject { app_config }
|
AmneziaFellowProject { app_config }
|
||||||
}
|
}
|
||||||
|
|||||||
+367
-1
@@ -1,11 +1,20 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use cot::db::Database;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::user::User;
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
const BOT_POLL_TIMEOUT_SECONDS: u64 = 25;
|
||||||
|
const BOT_IDLE_SLEEP: Duration = Duration::from_secs(15);
|
||||||
|
const BOT_ERROR_SLEEP: Duration = Duration::from_secs(5);
|
||||||
|
pub const TELEGRAM_LINK_CODE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
pub struct TelegramWebAppUser {
|
pub struct TelegramWebAppUser {
|
||||||
@@ -49,6 +58,363 @@ impl std::fmt::Display for TelegramAuthError {
|
|||||||
|
|
||||||
impl std::error::Error for TelegramAuthError {}
|
impl std::error::Error for TelegramAuthError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramApiResponse<T> {
|
||||||
|
ok: bool,
|
||||||
|
result: Option<T>,
|
||||||
|
description: Option<String>,
|
||||||
|
error_code: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramUpdate {
|
||||||
|
update_id: i64,
|
||||||
|
message: Option<TelegramMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramMessage {
|
||||||
|
chat: TelegramChat,
|
||||||
|
from: Option<TelegramBotUser>,
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramChat {
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramBotUser {
|
||||||
|
id: i64,
|
||||||
|
is_bot: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GetUpdatesRequest {
|
||||||
|
offset: Option<i64>,
|
||||||
|
limit: u8,
|
||||||
|
timeout: u64,
|
||||||
|
allowed_updates: [&'static str; 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DeleteWebhookRequest {
|
||||||
|
drop_pending_updates: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct SendMessageRequest<'a> {
|
||||||
|
chat_id: i64,
|
||||||
|
text: &'a str,
|
||||||
|
disable_web_page_preview: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_bot_worker(config: Arc<AppConfig>) {
|
||||||
|
if config.database_url.trim().is_empty() {
|
||||||
|
tracing::warn!("Telegram bot worker disabled: database URL is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_url = config.database_url.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
run_bot_worker(database_url).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_link_code() -> Result<String, String> {
|
||||||
|
let mut bytes = [0_u8; 12];
|
||||||
|
getrandom::fill(&mut bytes)
|
||||||
|
.map_err(|e| format!("failed to generate Telegram link code: {e}"))?;
|
||||||
|
Ok(format!("af-{}", hex::encode(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_unix_seconds() -> i64 {
|
||||||
|
let seconds = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or_default();
|
||||||
|
i64::try_from(seconds).unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link_code_expires_at(created_at: i64) -> i64 {
|
||||||
|
let ttl = i64::try_from(TELEGRAM_LINK_CODE_TTL.as_secs()).unwrap_or(i64::MAX);
|
||||||
|
created_at.saturating_add(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link_code_is_active(created_at: Option<i64>) -> bool {
|
||||||
|
created_at
|
||||||
|
.map(|created_at| now_unix_seconds() <= link_code_expires_at(created_at))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_link_code(text: &str) -> Option<String> {
|
||||||
|
text.split_whitespace().find_map(|part| {
|
||||||
|
let candidate = part.trim_matches(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'));
|
||||||
|
let candidate = candidate.to_ascii_lowercase();
|
||||||
|
let suffix = candidate.strip_prefix("af-")?;
|
||||||
|
(suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||||
|
.then_some(candidate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_bot_worker(database_url: String) {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(BOT_POLL_TIMEOUT_SECONDS + 10))
|
||||||
|
.build()
|
||||||
|
.expect("valid reqwest client");
|
||||||
|
let mut offset = None;
|
||||||
|
let mut active_token = String::new();
|
||||||
|
let mut webhook_deleted = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let db = match Database::new(database_url.clone()).await {
|
||||||
|
Ok(db) => db,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "Telegram bot worker could not open database");
|
||||||
|
tokio::time::sleep(BOT_ERROR_SLEEP).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let token = config.telegram_bot_token.trim().to_owned();
|
||||||
|
if !config.telegram_bot_enabled || token.is_empty() {
|
||||||
|
offset = None;
|
||||||
|
active_token.clear();
|
||||||
|
webhook_deleted = false;
|
||||||
|
tokio::time::sleep(BOT_IDLE_SLEEP).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if active_token != token {
|
||||||
|
active_token = token.clone();
|
||||||
|
offset = None;
|
||||||
|
webhook_deleted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !webhook_deleted {
|
||||||
|
match delete_webhook(&http, &token).await {
|
||||||
|
Ok(()) => webhook_deleted = true,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "Telegram bot worker could not delete webhook");
|
||||||
|
tokio::time::sleep(BOT_ERROR_SLEEP).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match get_updates(&http, &token, offset).await {
|
||||||
|
Ok(updates) => {
|
||||||
|
for update in updates {
|
||||||
|
offset = Some(update.update_id.saturating_add(1));
|
||||||
|
if let Err(e) = process_update(&db, &http, &token, update).await {
|
||||||
|
tracing::warn!(error = %e, "Telegram bot update was not processed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "Telegram bot polling failed");
|
||||||
|
tokio::time::sleep(BOT_ERROR_SLEEP).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_webhook(http: &reqwest::Client, token: &str) -> Result<(), String> {
|
||||||
|
let url = telegram_api_url(token, "deleteWebhook");
|
||||||
|
let response = http
|
||||||
|
.post(url)
|
||||||
|
.json(&DeleteWebhookRequest {
|
||||||
|
drop_pending_updates: false,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?
|
||||||
|
.json::<TelegramApiResponse<bool>>()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?;
|
||||||
|
telegram_api_result(response).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_updates(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
offset: Option<i64>,
|
||||||
|
) -> Result<Vec<TelegramUpdate>, String> {
|
||||||
|
let url = telegram_api_url(token, "getUpdates");
|
||||||
|
let response = http
|
||||||
|
.post(url)
|
||||||
|
.json(&GetUpdatesRequest {
|
||||||
|
offset,
|
||||||
|
limit: 50,
|
||||||
|
timeout: BOT_POLL_TIMEOUT_SECONDS,
|
||||||
|
allowed_updates: ["message"],
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?
|
||||||
|
.json::<TelegramApiResponse<Vec<TelegramUpdate>>>()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?;
|
||||||
|
telegram_api_result(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
chat_id: i64,
|
||||||
|
text: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let url = telegram_api_url(token, "sendMessage");
|
||||||
|
let response = http
|
||||||
|
.post(url)
|
||||||
|
.json(&SendMessageRequest {
|
||||||
|
chat_id,
|
||||||
|
text,
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?
|
||||||
|
.json::<TelegramApiResponse<serde_json::Value>>()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?;
|
||||||
|
telegram_api_result(response).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_update(
|
||||||
|
db: &Database,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
update: TelegramUpdate,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let Some(message) = update.message else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(from) = message.from else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if from.is_bot {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(text) = message.text.as_deref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if text.trim().starts_with("/start") {
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"Open the VPN portal, press the Telegram button and send me the secret code shown there.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(code) = normalize_link_code(text) else {
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"I need the secret code from the VPN portal to connect your Telegram account.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mut user) = User::get_by_telegram_link_code(db, &code)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to load Telegram link code: {e}"))?
|
||||||
|
else {
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"This code is unknown or already used. Generate a new code in the VPN portal.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if !link_code_is_active(user.telegram_link_code_created_at()) {
|
||||||
|
user.set_telegram_link_code(db, None, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to clear expired Telegram link code: {e}"))?;
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"This code has expired. Generate a new code in the VPN portal.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let telegram_id = from.id.to_string();
|
||||||
|
if let Some(existing) = User::get_by_telegram_id(db, &telegram_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to check Telegram ID: {e}"))?
|
||||||
|
{
|
||||||
|
if existing.id_val() != user.id_val() {
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"This Telegram account is already connected to another VPN account.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.complete_telegram_link(db, &telegram_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to save Telegram ID: {e}"))?;
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"Telegram connected. You can return to the VPN portal.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn telegram_api_url(token: &str, method: &str) -> String {
|
||||||
|
format!("https://api.telegram.org/bot{token}/{method}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn telegram_api_result<T>(response: TelegramApiResponse<T>) -> Result<T, String> {
|
||||||
|
if response.ok {
|
||||||
|
response
|
||||||
|
.result
|
||||||
|
.ok_or_else(|| "Telegram API response did not include result".to_owned())
|
||||||
|
} else {
|
||||||
|
Err(match (response.error_code, response.description) {
|
||||||
|
(Some(code), Some(description)) => format!("Telegram API {code}: {description}"),
|
||||||
|
(_, Some(description)) => description,
|
||||||
|
(Some(code), None) => format!("Telegram API error {code}"),
|
||||||
|
(None, None) => "Telegram API error".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reqwest_error_message(error: reqwest::Error) -> String {
|
||||||
|
if error.is_timeout() {
|
||||||
|
"request timed out".to_owned()
|
||||||
|
} else if error.is_connect() {
|
||||||
|
"connection failed".to_owned()
|
||||||
|
} else if let Some(status) = error.status() {
|
||||||
|
format!("HTTP {status}")
|
||||||
|
} else {
|
||||||
|
"request failed".to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn validate_web_app_init_data(
|
pub fn validate_web_app_init_data(
|
||||||
init_data: &str,
|
init_data: &str,
|
||||||
bot_token: &str,
|
bot_token: &str,
|
||||||
|
|||||||
+110
@@ -18,6 +18,8 @@ pub struct User {
|
|||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
avatar_url: Option<String>,
|
avatar_url: Option<String>,
|
||||||
telegram_id: Option<String>,
|
telegram_id: Option<String>,
|
||||||
|
telegram_link_code: Option<String>,
|
||||||
|
telegram_link_code_created_at: Option<i64>,
|
||||||
role: LimitedString<32>,
|
role: LimitedString<32>,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
}
|
}
|
||||||
@@ -55,6 +57,8 @@ impl User {
|
|||||||
display_name: display_name.map(str::to_owned),
|
display_name: display_name.map(str::to_owned),
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
telegram_id: None,
|
telegram_id: None,
|
||||||
|
telegram_link_code: None,
|
||||||
|
telegram_link_code_created_at: None,
|
||||||
role: LimitedString::new(role).unwrap(),
|
role: LimitedString::new(role).unwrap(),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
};
|
};
|
||||||
@@ -78,6 +82,8 @@ impl User {
|
|||||||
display_name: display_name.map(str::to_owned),
|
display_name: display_name.map(str::to_owned),
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
telegram_id: None,
|
telegram_id: None,
|
||||||
|
telegram_link_code: None,
|
||||||
|
telegram_link_code_created_at: None,
|
||||||
role: LimitedString::new(role).unwrap(),
|
role: LimitedString::new(role).unwrap(),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
};
|
};
|
||||||
@@ -135,6 +141,21 @@ impl User {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find a user waiting for this Telegram link code.
|
||||||
|
pub async fn get_by_telegram_link_code(
|
||||||
|
db: &Database,
|
||||||
|
code: &str,
|
||||||
|
) -> cot::db::Result<Option<Self>> {
|
||||||
|
let code = code.trim();
|
||||||
|
if code.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let code = code.to_owned();
|
||||||
|
cot::db::query!(User, $telegram_link_code == Some(code))
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Count all users in the database.
|
/// Count all users in the database.
|
||||||
pub async fn count_all(db: &Database) -> cot::db::Result<u64> {
|
pub async fn count_all(db: &Database) -> cot::db::Result<u64> {
|
||||||
Self::objects().count(db).await
|
Self::objects().count(db).await
|
||||||
@@ -168,6 +189,46 @@ impl User {
|
|||||||
self.save(db).await
|
self.save(db).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store or clear a pending Telegram link code.
|
||||||
|
pub async fn set_telegram_link_code(
|
||||||
|
&mut self,
|
||||||
|
db: &Database,
|
||||||
|
code: Option<&str>,
|
||||||
|
created_at: Option<i64>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
self.telegram_link_code = code.map(str::to_owned);
|
||||||
|
self.telegram_link_code_created_at = code.and(created_at);
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete Telegram linking and clear any pending code.
|
||||||
|
pub async fn complete_telegram_link(
|
||||||
|
&mut self,
|
||||||
|
db: &Database,
|
||||||
|
telegram_id: &str,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
self.telegram_id = Some(telegram_id.to_owned());
|
||||||
|
self.telegram_link_code = None;
|
||||||
|
self.telegram_link_code_created_at = None;
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove Telegram credentials and pending link data.
|
||||||
|
pub async fn clear_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
self.telegram_id = None;
|
||||||
|
self.telegram_link_code = None;
|
||||||
|
self.telegram_link_code_created_at = None;
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an explicit opt-out and clear pending link data.
|
||||||
|
pub async fn decline_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
self.telegram_id = Some(String::new());
|
||||||
|
self.telegram_link_code = None;
|
||||||
|
self.telegram_link_code_created_at = None;
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete this user by primary key.
|
/// Delete this user by primary key.
|
||||||
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
|
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
|
||||||
cot::db::query!(User, $id == Auto::Fixed(user_id))
|
cot::db::query!(User, $id == Auto::Fixed(user_id))
|
||||||
@@ -197,6 +258,14 @@ impl User {
|
|||||||
self.telegram_id.as_deref()
|
self.telegram_id.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn telegram_link_code(&self) -> Option<&str> {
|
||||||
|
self.telegram_link_code.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn telegram_link_code_created_at(&self) -> Option<i64> {
|
||||||
|
self.telegram_link_code_created_at
|
||||||
|
}
|
||||||
|
|
||||||
pub fn role_str(&self) -> &str {
|
pub fn role_str(&self) -> &str {
|
||||||
&self.role
|
&self.role
|
||||||
}
|
}
|
||||||
@@ -454,10 +523,51 @@ pub mod db_migrations {
|
|||||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_user_telegram_id).build()];
|
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_user_telegram_id).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- M0008: pending Telegram link code on amnezia_fellow__user ---------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn add_user_telegram_link_code(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw("ALTER TABLE amnezia_fellow__user ADD COLUMN telegram_link_code TEXT")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE amnezia_fellow__user \
|
||||||
|
ADD COLUMN telegram_link_code_created_at INTEGER",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_amnezia_fellow_user_telegram_link_code \
|
||||||
|
ON amnezia_fellow__user (telegram_link_code) \
|
||||||
|
WHERE telegram_link_code IS NOT NULL AND telegram_link_code != ''",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0008UserTelegramLinkCode;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0008UserTelegramLinkCode {
|
||||||
|
const APP_NAME: &'static str = "amnezia_fellow";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0008_user_telegram_link_code";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"amnezia_fellow",
|
||||||
|
"m_0007_user_telegram_id",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(add_user_telegram_link_code).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0002CreateUser,
|
&M0002CreateUser,
|
||||||
&M0003CreateOidcLink,
|
&M0003CreateOidcLink,
|
||||||
&M0004OidcLinkIndexes,
|
&M0004OidcLinkIndexes,
|
||||||
&M0007UserTelegramId,
|
&M0007UserTelegramId,
|
||||||
|
&M0008UserTelegramLinkCode,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-14
@@ -27,6 +27,13 @@
|
|||||||
.brand-version { color: #7a858d; font-size: .75rem; }
|
.brand-version { color: #7a858d; font-size: .75rem; }
|
||||||
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: .35rem; flex-wrap: wrap; }
|
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: .35rem; flex-wrap: wrap; }
|
||||||
.user-pill { color: #53616c; font-size: .86rem; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.user-pill { color: #53616c; font-size: .86rem; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.telegram-status-button { min-width: 38px; width: 38px; height: 38px; min-height: 38px; padding: 0; display: inline-grid; place-items: center; position: relative; border-color: #c7d4ce; background: #fff; color: #18342f; }
|
||||||
|
.telegram-status-button .telegram-mark { font-size: .72rem; font-weight: 900; letter-spacing: 0; }
|
||||||
|
.telegram-dot { position: absolute; right: 5px; top: 5px; width: .56rem; height: .56rem; border-radius: 999px; border: 2px solid #fff; background: #8a969e; }
|
||||||
|
.telegram-status-button.tone-connected .telegram-dot { background: #2f8f4e; }
|
||||||
|
.telegram-status-button.tone-pending .telegram-dot { background: #d39a00; }
|
||||||
|
.telegram-status-button.tone-empty .telegram-dot { background: #69777f; }
|
||||||
|
.telegram-status-button.tone-disabled .telegram-dot { background: #a8312d; }
|
||||||
.lang-switch { display: inline-flex; align-items: center; border: 1px solid #d6dfda; border-radius: 8px; background: #f7faf8; overflow: hidden; }
|
.lang-switch { display: inline-flex; align-items: center; border: 1px solid #d6dfda; border-radius: 8px; background: #f7faf8; overflow: hidden; }
|
||||||
.lang-switch a { min-width: 38px; padding: .38rem .5rem; color: #53616c; text-align: center; text-decoration: none; font-size: .82rem; font-weight: 800; }
|
.lang-switch a { min-width: 38px; padding: .38rem .5rem; color: #53616c; text-align: center; text-decoration: none; font-size: .82rem; font-weight: 800; }
|
||||||
.lang-switch a.active { background: #18342f; color: #fff; }
|
.lang-switch a.active { background: #18342f; color: #fff; }
|
||||||
@@ -111,6 +118,8 @@
|
|||||||
.guide-list { margin: 0; padding-left: 1.2rem; color: #34424b; display: grid; gap: .45rem; line-height: 1.4; }
|
.guide-list { margin: 0; padding-left: 1.2rem; color: #34424b; display: grid; gap: .45rem; line-height: 1.4; }
|
||||||
.guide-list a { color: #18342f; font-weight: 850; }
|
.guide-list a { color: #18342f; font-weight: 850; }
|
||||||
.guide-list a.disabled { color: #69777f; pointer-events: none; text-decoration: none; }
|
.guide-list a.disabled { color: #69777f; pointer-events: none; text-decoration: none; }
|
||||||
|
.secret-box { display: grid; gap: .35rem; border: 1px solid #d6dfda; border-radius: 8px; background: #f7faf8; padding: .7rem; }
|
||||||
|
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.topbar-inner { align-items: flex-start; flex-direction: column; }
|
.topbar-inner { align-items: flex-start; flex-direction: column; }
|
||||||
.top-actions { width: 100%; justify-content: space-between; }
|
.top-actions { width: 100%; justify-content: space-between; }
|
||||||
@@ -150,6 +159,10 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<span class="user-pill">{{ user_name }} ({{ user_role }})</span>
|
<span class="user-pill">{{ user_name }} ({{ user_role }})</span>
|
||||||
|
<button type="button" x-show="telegram.enabled" class="telegram-status-button" :class="`tone-${telegramStatusTone()}`" @click="openTelegramManage()" :title="telegramStatusTitle()" :aria-label="telegramStatusTitle()">
|
||||||
|
<span class="telegram-mark">TG</span>
|
||||||
|
<span class="telegram-dot"></span>
|
||||||
|
</button>
|
||||||
<div class="lang-switch">
|
<div class="lang-switch">
|
||||||
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
|
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
|
||||||
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
|
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
|
||||||
@@ -341,6 +354,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template x-if="telegramModal === 'manage'">
|
||||||
|
<div>
|
||||||
|
<p class="telegram-copy" x-text="telegramManageMessage()"></p>
|
||||||
|
<template x-if="telegram.linked">
|
||||||
|
<div class="secret-box">
|
||||||
|
<span>{{ t.telegram_status_connected }}</span>
|
||||||
|
<code x-text="telegram.telegram_id || ''"></code>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="telegram.pending">
|
||||||
|
<div class="secret-box">
|
||||||
|
<span>{{ t.telegram_secret_label }}</span>
|
||||||
|
<code x-text="telegram.pending_secret || ''"></code>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="secondary" @click="telegramModal = null">{{ t.admin_close }}</button>
|
||||||
|
<template x-if="telegram.pending">
|
||||||
|
<button type="button" class="secondary" @click="copyTelegramSecret()" :disabled="telegramBusy || !telegram.pending_secret">{{ t.telegram_copy_secret }}</button>
|
||||||
|
</template>
|
||||||
|
<template x-if="telegram.pending">
|
||||||
|
<button type="button" class="secondary" @click="refreshTelegramStatus()" :disabled="telegramBusy">{{ t.telegram_refresh_status }}</button>
|
||||||
|
</template>
|
||||||
|
<button type="button" class="secondary" @click="startTelegramBotLink()" :disabled="telegramBusy" x-text="telegram.linked || telegram.pending ? '{{ t.telegram_change }}' : '{{ t.telegram_connect }}'"></button>
|
||||||
|
<template x-if="telegram.linked || telegram.pending || telegram.declined">
|
||||||
|
<button type="button" class="danger" @click="deleteTelegramLink()" :disabled="telegramBusy">{{ t.telegram_delete }}</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template x-if="telegramModal === 'webapp'">
|
<template x-if="telegramModal === 'webapp'">
|
||||||
<div>
|
<div>
|
||||||
<p class="telegram-copy">{{ t.telegram_webapp_message }}</p>
|
<p class="telegram-copy">{{ t.telegram_webapp_message }}</p>
|
||||||
@@ -356,7 +400,7 @@
|
|||||||
<p class="telegram-copy">{{ t.telegram_manual_message }}</p>
|
<p class="telegram-copy">{{ t.telegram_manual_message }}</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
||||||
<button type="button" @click="telegramModal = 'manualGuide'">{{ t.telegram_connect }}</button>
|
<button type="button" @click="startTelegramBotLink()" :disabled="telegramBusy">{{ t.telegram_connect }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -369,11 +413,14 @@
|
|||||||
<li>{{ t.telegram_guide_get_id }}</li>
|
<li>{{ t.telegram_guide_get_id }}</li>
|
||||||
<li>{{ t.telegram_guide_paste }}</li>
|
<li>{{ t.telegram_guide_paste }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<label for="telegram-id">{{ t.telegram_id_label }}</label>
|
<div class="secret-box">
|
||||||
<input id="telegram-id" x-model="telegramManualId" inputmode="numeric" pattern="[0-9]*" placeholder="{{ t.telegram_id_placeholder }}" @keydown.enter.prevent="saveTelegramManual()">
|
<span>{{ t.telegram_secret_label }}</span>
|
||||||
|
<code x-text="telegram.pending_secret || ''"></code>
|
||||||
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
||||||
<button type="button" @click="saveTelegramManual()" :disabled="telegramBusy || !telegramManualId.trim()">{{ t.telegram_save }}</button>
|
<button type="button" class="secondary" @click="copyTelegramSecret()" :disabled="telegramBusy || !telegram.pending_secret">{{ t.telegram_copy_secret }}</button>
|
||||||
|
<button type="button" @click="refreshTelegramStatus()" :disabled="telegramBusy">{{ t.telegram_refresh_status }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -404,12 +451,14 @@ function clientPortal() {
|
|||||||
bot_url: '',
|
bot_url: '',
|
||||||
linked: false,
|
linked: false,
|
||||||
declined: false,
|
declined: false,
|
||||||
|
pending: false,
|
||||||
|
pending_secret: null,
|
||||||
|
pending_expires_at: null,
|
||||||
telegram_id: null,
|
telegram_id: null,
|
||||||
isWebApp: false,
|
isWebApp: false,
|
||||||
initData: '',
|
initData: '',
|
||||||
},
|
},
|
||||||
telegramModal: null,
|
telegramModal: null,
|
||||||
telegramManualId: '',
|
|
||||||
telegramBusy: false,
|
telegramBusy: false,
|
||||||
telegramPromptChecked: false,
|
telegramPromptChecked: false,
|
||||||
init() {
|
init() {
|
||||||
@@ -485,13 +534,24 @@ function clientPortal() {
|
|||||||
bot_url: status.bot_url || '',
|
bot_url: status.bot_url || '',
|
||||||
linked: Boolean(status.linked),
|
linked: Boolean(status.linked),
|
||||||
declined: Boolean(status.declined),
|
declined: Boolean(status.declined),
|
||||||
|
pending: Boolean(status.pending),
|
||||||
|
pending_secret: status.pending_secret || null,
|
||||||
|
pending_expires_at: status.pending_expires_at || null,
|
||||||
telegram_id: status.telegram_id || null,
|
telegram_id: status.telegram_id || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
maybePromptTelegram() {
|
maybePromptTelegram() {
|
||||||
if (this.telegramPromptChecked) return;
|
if (this.telegramPromptChecked) return;
|
||||||
this.telegramPromptChecked = true;
|
this.telegramPromptChecked = true;
|
||||||
if (!this.telegram.enabled || this.telegram.linked || this.telegram.declined) return;
|
if (!this.telegram.enabled || this.telegram.linked || this.telegram.pending || this.telegram.declined) return;
|
||||||
|
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
|
||||||
|
},
|
||||||
|
openTelegramManage() {
|
||||||
|
if (!this.telegram.enabled) return;
|
||||||
|
if (this.telegram.linked || this.telegram.pending || this.telegram.declined) {
|
||||||
|
this.telegramModal = 'manage';
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
|
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
|
||||||
},
|
},
|
||||||
async linkTelegramWebApp() {
|
async linkTelegramWebApp() {
|
||||||
@@ -511,24 +571,41 @@ function clientPortal() {
|
|||||||
this.telegramBusy = false;
|
this.telegramBusy = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async saveTelegramManual() {
|
async startTelegramBotLink() {
|
||||||
this.telegramBusy = true;
|
this.telegramBusy = true;
|
||||||
this.clearNotice();
|
this.clearNotice();
|
||||||
try {
|
try {
|
||||||
const data = await this.request('/api/telegram-link/manual', {
|
const data = await this.request('/api/telegram-link/start', { method: 'POST' });
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ telegram_id: this.telegramManualId }),
|
|
||||||
});
|
|
||||||
this.applyTelegramStatus(data.status || {});
|
this.applyTelegramStatus(data.status || {});
|
||||||
this.telegramManualId = '';
|
this.telegramModal = 'manualGuide';
|
||||||
this.telegramModal = null;
|
|
||||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.showError(e);
|
this.showError(e);
|
||||||
} finally {
|
} finally {
|
||||||
this.telegramBusy = false;
|
this.telegramBusy = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async refreshTelegramStatus() {
|
||||||
|
this.telegramBusy = true;
|
||||||
|
try {
|
||||||
|
await this.loadTelegramStatus();
|
||||||
|
if (this.telegram.linked) {
|
||||||
|
this.telegramModal = 'manage';
|
||||||
|
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.telegramBusy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async copyTelegramSecret() {
|
||||||
|
if (!this.telegram.pending_secret) return;
|
||||||
|
this.clearNotice();
|
||||||
|
try {
|
||||||
|
await this.copyText(this.telegram.pending_secret);
|
||||||
|
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_secret_copied }}');
|
||||||
|
} catch (e) {
|
||||||
|
this.showError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
async declineTelegramLink() {
|
async declineTelegramLink() {
|
||||||
this.telegramBusy = true;
|
this.telegramBusy = true;
|
||||||
this.clearNotice();
|
this.clearNotice();
|
||||||
@@ -543,12 +620,44 @@ function clientPortal() {
|
|||||||
this.telegramBusy = false;
|
this.telegramBusy = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async deleteTelegramLink() {
|
||||||
|
if (!confirm('{{ t.telegram_delete_confirm }}')) return;
|
||||||
|
this.telegramBusy = true;
|
||||||
|
this.clearNotice();
|
||||||
|
try {
|
||||||
|
const data = await this.request('/api/telegram-link/unlink', { method: 'POST' });
|
||||||
|
this.applyTelegramStatus(data.status || {});
|
||||||
|
this.telegramModal = null;
|
||||||
|
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_unlinked }}');
|
||||||
|
} catch (e) {
|
||||||
|
this.showError(e);
|
||||||
|
} finally {
|
||||||
|
this.telegramBusy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
telegramBotUrl() {
|
telegramBotUrl() {
|
||||||
return this.telegram.bot_url || '#';
|
return this.telegram.bot_url || '#';
|
||||||
},
|
},
|
||||||
telegramBotLabel() {
|
telegramBotLabel() {
|
||||||
return this.telegram.bot_username ? `@${this.telegram.bot_username}` : '{{ t.telegram_bot_unconfigured }}';
|
return this.telegram.bot_username ? `@${this.telegram.bot_username}` : '{{ t.telegram_bot_unconfigured }}';
|
||||||
},
|
},
|
||||||
|
telegramStatusTone() {
|
||||||
|
if (!this.telegram.enabled) return 'disabled';
|
||||||
|
if (this.telegram.pending) return 'pending';
|
||||||
|
if (this.telegram.linked) return 'connected';
|
||||||
|
return 'empty';
|
||||||
|
},
|
||||||
|
telegramStatusTitle() {
|
||||||
|
if (!this.telegram.enabled) return '{{ t.telegram_status_disabled }}';
|
||||||
|
if (this.telegram.pending) return '{{ t.telegram_status_pending }}';
|
||||||
|
if (this.telegram.linked) return '{{ t.telegram_status_connected }}';
|
||||||
|
return '{{ t.telegram_status_empty }}';
|
||||||
|
},
|
||||||
|
telegramManageMessage() {
|
||||||
|
if (this.telegram.pending) return '{{ t.telegram_pending_message }}';
|
||||||
|
if (this.telegram.linked) return '{{ t.telegram_connected_message }}';
|
||||||
|
return '{{ t.telegram_not_connected_message }}';
|
||||||
|
},
|
||||||
async createClient() {
|
async createClient() {
|
||||||
this.clearNotice();
|
this.clearNotice();
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user