diff --git a/Cargo.lock b/Cargo.lock index 12cf73a..7cb3730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,7 +61,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "amnezia-fellow" -version = "0.1.3" +version = "0.1.4" dependencies = [ "base64 0.22.1", "cot", diff --git a/Cargo.toml b/Cargo.toml index 960805d..12a7bc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amnezia-fellow" -version = "0.1.4" +version = "0.1.5" edition = "2024" 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"] } serde = { version = "1", features = ["derive"] } openidconnect = "4.0" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } -tokio = { version = "1", features = ["sync"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tokio = { version = "1", features = ["rt", "sync", "time"] } base64 = "0.22" miniz_oxide = "0.8" qrcode = "0.14" diff --git a/README.md b/README.md index aad44ae..6553770 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,12 @@ AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN= ``` In BotFather, create or reuse the same bot and set its Web App URL to -`https:///configs`. The bot should reply with the sender's numeric -Telegram ID, and users must open the bot and send `/start` once before -notifications can be delivered. +`https:///configs`. This app runs the bot in the same process with +`getUpdates` polling, so do not configure a separate webhook for the same bot. +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 @@ -140,6 +143,11 @@ The JSON API is session-authenticated: - `GET /api/me` - `GET /api/vpn-clients` - `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/{id}/enabled` - `DELETE /api/vpn-clients/{id}` diff --git a/src/admin/views.rs b/src/admin/views.rs index a2a660a..eb802b0 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -839,8 +839,9 @@ fn telegram_setup_instructions(config: &AppConfig) -> String { AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN={token_value}\n\n\ BotFather:\n\ 1. Create or reuse this bot and set the Web App URL to https:///configs.\n\ - 2. Make the bot reply with the sender numeric Telegram ID.\n\ - 3. Users must open the bot and send /start once before notifications can work." + 2. Keep webhook disabled: this process receives bot messages with getUpdates polling.\n\ + 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." ) } diff --git a/src/api/mod.rs b/src/api/mod.rs index 704c6ed..5c6809d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -151,6 +151,9 @@ struct TelegramLinkStatusResponse { bot_url: String, linked: bool, declined: bool, + pending: bool, + pending_secret: Option, + pending_expires_at: Option, telegram_id: Option, } @@ -164,11 +167,6 @@ struct TelegramWebAppLinkRequest { init_data: String, } -#[derive(Debug, Deserialize, JsonSchema)] -struct TelegramManualLinkRequest { - telegram_id: String, -} - const TELEGRAM_INIT_DATA_MAX_AGE: Duration = Duration::from_secs(86_400); async fn vpn_clients_handler( @@ -294,10 +292,9 @@ async fn telegram_link_webapp_handler( .into_response() } -async fn telegram_link_manual_handler( +async fn telegram_link_start_handler( session: Session, db: Database, - Json(request): Json, ) -> cot::Result { let mut user = match api_user_record(&session, &db).await? { Ok(user) => user, @@ -314,12 +311,15 @@ async fn telegram_link_manual_handler( )); } - let telegram_id = match normalize_manual_telegram_id(&request.telegram_id) { - Ok(value) => value, - Err(response) => return Ok(response), - }; - if let Some(response) = link_telegram_id(&db, &mut user, &telegram_id).await? { - return Ok(response); + let code = create_unique_telegram_link_code(&db).await?; + let now = telegram::now_unix_seconds(); + user.set_telegram_link_code(&db, Some(&code), Some(now)) + .await + .map_err(|e| cot::Error::internal(format!("failed to save Telegram link code: {e}")))?; + 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 { @@ -347,7 +347,7 @@ async fn telegram_link_decline_handler( )); } - user.set_telegram_id(&db, Some("")) + user.decline_telegram_link(&db) .await .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() } +async fn telegram_link_unlink_handler( + session: Session, + db: Database, +) -> cot::Result { + 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( session: Session, db: Database, @@ -626,6 +646,17 @@ fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStat .as_deref() .is_some_and(|value| !value.is_empty()); 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 { enabled: config.telegram_bot_enabled, @@ -633,6 +664,9 @@ fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStat bot_username, linked, declined, + pending, + pending_secret, + pending_expires_at, 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 { - let telegram_id = telegram_id.trim(); - if telegram_id.is_empty() - || telegram_id.len() > 32 - || !telegram_id.bytes().all(|byte| byte.is_ascii_digit()) - { - return Err(json_error_typed( - cot::http::StatusCode::BAD_REQUEST, - "telegram_id_invalid", - "Telegram ID is invalid", - "Paste the numeric Telegram ID returned by the bot.", - "", - )); +async fn create_unique_telegram_link_code(db: &Database) -> cot::Result { + for _ in 0..8 { + let code = telegram::generate_link_code().map_err(cot::Error::internal)?; + let existing = User::get_by_telegram_link_code(db, &code) + .await + .map_err(|e| { + cot::Error::internal(format!("failed to check Telegram link code: {e}")) + })?; + if existing.is_none() { + return Ok(code); + } } - Ok(telegram_id.to_owned()) + Err(cot::Error::internal( + "failed to generate unique Telegram link code", + )) } 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 .map_err(|e| cot::Error::internal(format!("failed to save Telegram ID: {e}")))?; Ok(None) @@ -798,15 +832,20 @@ impl App for ApiApp { "api_telegram_link_webapp", ), Route::with_api_handler_and_name( - "/telegram-link/manual", - api_post(telegram_link_manual_handler), - "api_telegram_link_manual", + "/telegram-link/start", + api_post(telegram_link_start_handler), + "api_telegram_link_start", ), Route::with_api_handler_and_name( "/telegram-link/decline", api_post(telegram_link_decline_handler), "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( "/vpn-clients/sync", api_post(sync_vpn_clients_handler), diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index d4960a6..ad46eb2 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -162,20 +162,33 @@ translations! { notice_detail: "Detail" , "Детали"; telegram_link_title: "Connect Telegram" , "Подключить 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_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_guide_start: "Open the bot and send /start once." , "Откройте бота и один раз отправьте /start."; - telegram_guide_get_id: "Ask the bot for your ID; it will reply with only the number." , "Напишите боту, он ответит только номером вашего ID."; - telegram_guide_paste: "Paste that number here and save it." , "Вставьте этот номер сюда и сохраните."; - telegram_id_label: "Telegram ID" , "Telegram ID"; - telegram_id_placeholder: "Only digits" , "Только цифры"; + telegram_guide_start: "Open the bot and send /start once if you have not done it before." , "Откройте бота и один раз отправьте /start, если ещё не делали этого."; + telegram_guide_get_id: "Send the secret code below to the bot." , "Отправьте боту секретный код ниже."; + telegram_guide_paste: "Return here and refresh the status." , "Вернитесь сюда и обновите статус."; + telegram_secret_label: "Secret code" , "Секретный код"; telegram_save: "Save Telegram" , "Сохранить Telegram"; telegram_saved: "Telegram connected." , "Telegram подключён."; telegram_declined: "Telegram prompt disabled." , "Предложение Telegram отключено."; 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 servers_empty: "No registered servers." , "Нет зарегистрированных серверов."; diff --git a/src/main.rs b/src/main.rs index 5a7ebf6..86a931a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -412,6 +412,7 @@ fn main() -> impl Project { tracing_subscriber::fmt().with_env_filter(filter).init(); tracing::info!("loaded config: {:?}", app_config); + telegram::spawn_bot_worker(Arc::clone(&app_config)); AmneziaFellowProject { app_config } } diff --git a/src/telegram.rs b/src/telegram.rs index 83e3ea0..f84e5d7 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -1,11 +1,20 @@ +use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use cot::db::Database; use hmac::{Hmac, Mac}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sha2::Sha256; use url::form_urlencoded; +use crate::config::AppConfig; +use crate::user::User; + type HmacSha256 = Hmac; +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)] pub struct TelegramWebAppUser { @@ -49,6 +58,363 @@ impl std::fmt::Display for TelegramAuthError { impl std::error::Error for TelegramAuthError {} +#[derive(Debug, Deserialize)] +struct TelegramApiResponse { + ok: bool, + result: Option, + description: Option, + error_code: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdate { + update_id: i64, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramMessage { + chat: TelegramChat, + from: Option, + text: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramChat { + id: i64, +} + +#[derive(Debug, Deserialize)] +struct TelegramBotUser { + id: i64, + is_bot: bool, +} + +#[derive(Debug, Serialize)] +struct GetUpdatesRequest { + offset: Option, + 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) { + 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 { + 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) -> 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 { + 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::>() + .await + .map_err(reqwest_error_message)?; + telegram_api_result(response).map(|_| ()) +} + +async fn get_updates( + http: &reqwest::Client, + token: &str, + offset: Option, +) -> Result, 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::>>() + .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::>() + .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(response: TelegramApiResponse) -> Result { + 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( init_data: &str, bot_token: &str, diff --git a/src/user.rs b/src/user.rs index 00ed04a..2f28b86 100644 --- a/src/user.rs +++ b/src/user.rs @@ -18,6 +18,8 @@ pub struct User { display_name: Option, avatar_url: Option, telegram_id: Option, + telegram_link_code: Option, + telegram_link_code_created_at: Option, role: LimitedString<32>, is_active: bool, } @@ -55,6 +57,8 @@ impl User { display_name: display_name.map(str::to_owned), avatar_url: None, telegram_id: None, + telegram_link_code: None, + telegram_link_code_created_at: None, role: LimitedString::new(role).unwrap(), is_active: true, }; @@ -78,6 +82,8 @@ impl User { display_name: display_name.map(str::to_owned), avatar_url: None, telegram_id: None, + telegram_link_code: None, + telegram_link_code_created_at: None, role: LimitedString::new(role).unwrap(), is_active: true, }; @@ -135,6 +141,21 @@ impl User { .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> { + 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. pub async fn count_all(db: &Database) -> cot::db::Result { Self::objects().count(db).await @@ -168,6 +189,46 @@ impl User { 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, + ) -> 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. pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> { cot::db::query!(User, $id == Auto::Fixed(user_id)) @@ -197,6 +258,14 @@ impl User { 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 { + self.telegram_link_code_created_at + } + pub fn role_str(&self) -> &str { &self.role } @@ -454,10 +523,51 @@ pub mod db_migrations { 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] = &[ &M0002CreateUser, &M0003CreateOidcLink, &M0004OidcLinkIndexes, &M0007UserTelegramId, + &M0008UserTelegramLinkCode, ]; } diff --git a/templates/client_portal.html b/templates/client_portal.html index d534ba1..4845f57 100644 --- a/templates/client_portal.html +++ b/templates/client_portal.html @@ -27,6 +27,13 @@ .brand-version { color: #7a858d; font-size: .75rem; } .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; } + .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 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; } @@ -111,6 +118,8 @@ .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.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) { .topbar-inner { align-items: flex-start; flex-direction: column; } .top-actions { width: 100%; justify-content: space-between; } @@ -150,6 +159,10 @@
{{ user_name }} ({{ user_role }}) +
EN RU @@ -341,6 +354,37 @@
+ + @@ -369,11 +413,14 @@
  • {{ t.telegram_guide_get_id }}
  • {{ t.telegram_guide_paste }}
  • - - +
    + {{ t.telegram_secret_label }} + +
    @@ -404,12 +451,14 @@ function clientPortal() { bot_url: '', linked: false, declined: false, + pending: false, + pending_secret: null, + pending_expires_at: null, telegram_id: null, isWebApp: false, initData: '', }, telegramModal: null, - telegramManualId: '', telegramBusy: false, telegramPromptChecked: false, init() { @@ -485,13 +534,24 @@ function clientPortal() { bot_url: status.bot_url || '', linked: Boolean(status.linked), 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, }; }, maybePromptTelegram() { if (this.telegramPromptChecked) return; 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'; }, async linkTelegramWebApp() { @@ -511,24 +571,41 @@ function clientPortal() { this.telegramBusy = false; } }, - async saveTelegramManual() { + async startTelegramBotLink() { this.telegramBusy = true; this.clearNotice(); try { - const data = await this.request('/api/telegram-link/manual', { - method: 'POST', - body: JSON.stringify({ telegram_id: this.telegramManualId }), - }); + const data = await this.request('/api/telegram-link/start', { method: 'POST' }); this.applyTelegramStatus(data.status || {}); - this.telegramManualId = ''; - this.telegramModal = null; - this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}'); + this.telegramModal = 'manualGuide'; } catch (e) { this.showError(e); } finally { 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() { this.telegramBusy = true; this.clearNotice(); @@ -543,12 +620,44 @@ function clientPortal() { 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() { return this.telegram.bot_url || '#'; }, telegramBotLabel() { 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() { this.clearNotice(); this.busy = true;