From d972f10f8378946286df65d961259a76dc36e17e Mon Sep 17 00:00:00 2001 From: "AB from home.homenet" Date: Sun, 19 Oct 2025 04:13:36 +0300 Subject: [PATCH] TG almost works --- src/database/entities/mod.rs | 2 + src/database/entities/user_request.rs | 172 +++++ ...51018_000001_create_user_requests_table.rs | 193 +++++ ...251018_000002_remove_unique_telegram_id.rs | 38 + ...18_000003_add_language_to_user_requests.rs | 41 + src/database/migrations/mod.rs | 6 + src/database/repository/inbound_users.rs | 1 + src/database/repository/mod.rs | 4 +- src/database/repository/user.rs | 19 +- src/database/repository/user_request.rs | 131 ++++ src/services/tasks.rs | 55 +- src/services/telegram/bot.rs | 19 +- src/services/telegram/handlers.rs | 371 --------- src/services/telegram/handlers/admin.rs | 723 ++++++++++++++++++ src/services/telegram/handlers/mod.rs | 153 ++++ src/services/telegram/handlers/types.rs | 137 ++++ src/services/telegram/handlers/user.rs | 669 ++++++++++++++++ src/services/telegram/localization/mod.rs | 297 +++++++ src/services/telegram/mod.rs | 1 + src/services/uri_generator/builders/mod.rs | 4 +- .../uri_generator/builders/shadowsocks.rs | 2 +- src/services/uri_generator/builders/trojan.rs | 2 +- src/services/uri_generator/builders/vless.rs | 2 +- src/services/uri_generator/builders/vmess.rs | 4 +- src/services/uri_generator/mod.rs | 3 + src/services/xray/client.rs | 29 +- src/services/xray/mod.rs | 25 +- src/web/handlers/mod.rs | 4 +- src/web/handlers/user_requests.rs | 224 ++++++ src/web/routes/mod.rs | 12 +- static/admin.html | 386 ++++++++++ 31 files changed, 3302 insertions(+), 427 deletions(-) create mode 100644 src/database/entities/user_request.rs create mode 100644 src/database/migrations/m20251018_000001_create_user_requests_table.rs create mode 100644 src/database/migrations/m20251018_000002_remove_unique_telegram_id.rs create mode 100644 src/database/migrations/m20251018_000003_add_language_to_user_requests.rs create mode 100644 src/database/repository/user_request.rs delete mode 100644 src/services/telegram/handlers.rs create mode 100644 src/services/telegram/handlers/admin.rs create mode 100644 src/services/telegram/handlers/mod.rs create mode 100644 src/services/telegram/handlers/types.rs create mode 100644 src/services/telegram/handlers/user.rs create mode 100644 src/services/telegram/localization/mod.rs create mode 100644 src/web/handlers/user_requests.rs diff --git a/src/database/entities/mod.rs b/src/database/entities/mod.rs index 5c21f89..d010aad 100644 --- a/src/database/entities/mod.rs +++ b/src/database/entities/mod.rs @@ -7,6 +7,7 @@ pub mod server_inbound; pub mod user_access; pub mod inbound_users; pub mod telegram_config; +pub mod user_request; pub mod prelude { pub use super::user::Entity as User; @@ -18,4 +19,5 @@ pub mod prelude { pub use super::user_access::Entity as UserAccess; pub use super::inbound_users::Entity as InboundUsers; pub use super::telegram_config::Entity as TelegramConfig; + pub use super::user_request::Entity as UserRequest; } \ No newline at end of file diff --git a/src/database/entities/user_request.rs b/src/database/entities/user_request.rs new file mode 100644 index 0000000..af99b7c --- /dev/null +++ b/src/database/entities/user_request.rs @@ -0,0 +1,172 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user_requests")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub user_id: Option, + pub telegram_id: i64, + pub telegram_username: Option, + pub telegram_first_name: Option, + pub telegram_last_name: Option, + pub status: String, // pending, approved, declined + pub request_message: Option, + pub response_message: Option, + pub processed_by_user_id: Option, + pub processed_at: Option, + pub language: String, // User's language preference (en, ru) + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "SetNull" + )] + User, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::ProcessedByUserId", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "SetNull" + )] + ProcessedByUser, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +// Request status enum +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RequestStatus { + Pending, + Approved, + Declined, +} + +impl RequestStatus { + pub fn as_str(&self) -> &'static str { + match self { + RequestStatus::Pending => "pending", + RequestStatus::Approved => "approved", + RequestStatus::Declined => "declined", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "pending" => Some(RequestStatus::Pending), + "approved" => Some(RequestStatus::Approved), + "declined" => Some(RequestStatus::Declined), + _ => None, + } + } +} + +impl Model { + pub fn get_status(&self) -> RequestStatus { + RequestStatus::from_str(&self.status).unwrap_or(RequestStatus::Pending) + } + + pub fn get_full_name(&self) -> String { + let mut parts = vec![]; + if let Some(first) = &self.telegram_first_name { + parts.push(first.clone()); + } + if let Some(last) = &self.telegram_last_name { + parts.push(last.clone()); + } + if parts.is_empty() { + self.telegram_username.clone().unwrap_or_else(|| format!("User {}", self.telegram_id)) + } else { + parts.join(" ") + } + } + + pub fn get_telegram_link(&self) -> String { + if let Some(username) = &self.telegram_username { + format!("@{}", username) + } else { + format!("tg://user?id={}", self.telegram_id) + } + } + + pub fn get_language(&self) -> String { + self.language.clone() + } +} + +// DTOs for creating and updating user requests +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateUserRequestDto { + pub telegram_id: i64, + pub telegram_username: Option, + pub telegram_first_name: Option, + pub telegram_last_name: Option, + pub request_message: Option, + pub language: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserRequestDto { + pub status: Option, + pub response_message: Option, + pub processed_by_user_id: Option, +} + +impl From for ActiveModel { + fn from(dto: CreateUserRequestDto) -> Self { + use sea_orm::ActiveValue::*; + + ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(None), + telegram_id: Set(dto.telegram_id), + telegram_username: Set(dto.telegram_username), + telegram_first_name: Set(dto.telegram_first_name), + telegram_last_name: Set(dto.telegram_last_name), + status: Set("pending".to_string()), + request_message: Set(dto.request_message), + response_message: Set(None), + processed_by_user_id: Set(None), + processed_at: Set(None), + language: Set(dto.language), + created_at: Set(chrono::Utc::now().into()), + updated_at: Set(chrono::Utc::now().into()), + } + } +} + +impl Model { + pub fn apply_update(self, dto: UpdateUserRequestDto, processed_by: Uuid) -> ActiveModel { + use sea_orm::ActiveValue::*; + + let mut active: ActiveModel = self.into(); + + if let Some(status) = dto.status { + active.status = Set(status); + active.processed_by_user_id = Set(Some(processed_by)); + active.processed_at = Set(Some(chrono::Utc::now().into())); + } + + if let Some(response) = dto.response_message { + active.response_message = Set(Some(response)); + } + + active.updated_at = Set(chrono::Utc::now().into()); + active + } +} \ No newline at end of file diff --git a/src/database/migrations/m20251018_000001_create_user_requests_table.rs b/src/database/migrations/m20251018_000001_create_user_requests_table.rs new file mode 100644 index 0000000..61a91ba --- /dev/null +++ b/src/database/migrations/m20251018_000001_create_user_requests_table.rs @@ -0,0 +1,193 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Create user_requests table + manager + .create_table( + Table::create() + .table(UserRequests::Table) + .if_not_exists() + .col( + ColumnDef::new(UserRequests::Id) + .uuid() + .not_null() + .primary_key() + .default(Expr::cust("gen_random_uuid()")), + ) + .col( + ColumnDef::new(UserRequests::UserId) + .uuid() + .null(), // Can be null if user doesn't exist yet + ) + .col( + ColumnDef::new(UserRequests::TelegramId) + .big_integer() + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(UserRequests::TelegramUsername) + .string() + .null(), + ) + .col( + ColumnDef::new(UserRequests::TelegramFirstName) + .string() + .null(), + ) + .col( + ColumnDef::new(UserRequests::TelegramLastName) + .string() + .null(), + ) + .col( + ColumnDef::new(UserRequests::Status) + .string() + .not_null() + .default("pending"), // pending, approved, declined + ) + .col( + ColumnDef::new(UserRequests::RequestMessage) + .text() + .null(), + ) + .col( + ColumnDef::new(UserRequests::ResponseMessage) + .text() + .null(), + ) + .col( + ColumnDef::new(UserRequests::ProcessedByUserId) + .uuid() + .null(), + ) + .col( + ColumnDef::new(UserRequests::ProcessedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(UserRequests::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(UserRequests::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_user_requests_user") + .from(UserRequests::Table, UserRequests::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_user_requests_processed_by") + .from(UserRequests::Table, UserRequests::ProcessedByUserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Create index on telegram_id for faster lookups + manager + .create_index( + Index::create() + .name("idx_user_requests_telegram_id") + .table(UserRequests::Table) + .col(UserRequests::TelegramId) + .to_owned(), + ) + .await?; + + // Create index on status for filtering + manager + .create_index( + Index::create() + .name("idx_user_requests_status") + .table(UserRequests::Table) + .col(UserRequests::Status) + .to_owned(), + ) + .await?; + + // Create trigger to update updated_at timestamp + manager + .get_connection() + .execute_unprepared( + r#" + CREATE OR REPLACE FUNCTION update_user_requests_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER user_requests_updated_at + BEFORE UPDATE ON user_requests + FOR EACH ROW + EXECUTE FUNCTION update_user_requests_updated_at(); + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Drop trigger and function + manager + .get_connection() + .execute_unprepared( + r#" + DROP TRIGGER IF EXISTS user_requests_updated_at ON user_requests; + DROP FUNCTION IF EXISTS update_user_requests_updated_at(); + "#, + ) + .await?; + + // Drop table + manager + .drop_table(Table::drop().table(UserRequests::Table).to_owned()) + .await + } +} + +#[derive(Iden)] +enum UserRequests { + Table, + Id, + UserId, + TelegramId, + TelegramUsername, + TelegramFirstName, + TelegramLastName, + Status, + RequestMessage, + ResponseMessage, + ProcessedByUserId, + ProcessedAt, + CreatedAt, + UpdatedAt, +} + +#[derive(Iden)] +enum Users { + Table, + Id, +} \ No newline at end of file diff --git a/src/database/migrations/m20251018_000002_remove_unique_telegram_id.rs b/src/database/migrations/m20251018_000002_remove_unique_telegram_id.rs new file mode 100644 index 0000000..9f7fa77 --- /dev/null +++ b/src/database/migrations/m20251018_000002_remove_unique_telegram_id.rs @@ -0,0 +1,38 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Drop the unique constraint on telegram_id + // This allows users to have multiple requests (e.g., if one was declined) + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE user_requests + DROP CONSTRAINT IF EXISTS user_requests_telegram_id_key; + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Re-add the unique constraint + manager + .get_connection() + .execute_unprepared( + r#" + ALTER TABLE user_requests + ADD CONSTRAINT user_requests_telegram_id_key UNIQUE (telegram_id); + "#, + ) + .await?; + + Ok(()) + } +} \ No newline at end of file diff --git a/src/database/migrations/m20251018_000003_add_language_to_user_requests.rs b/src/database/migrations/m20251018_000003_add_language_to_user_requests.rs new file mode 100644 index 0000000..0f24742 --- /dev/null +++ b/src/database/migrations/m20251018_000003_add_language_to_user_requests.rs @@ -0,0 +1,41 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Add language column to user_requests table + manager + .alter_table( + Table::alter() + .table(UserRequests::Table) + .add_column( + ColumnDef::new(UserRequests::Language) + .string() + .default("en") // Default to English + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Remove language column from user_requests table + manager + .alter_table( + Table::alter() + .table(UserRequests::Table) + .drop_column(UserRequests::Language) + .to_owned(), + ) + .await + } +} + +#[derive(Iden)] +enum UserRequests { + Table, + Language, +} \ No newline at end of file diff --git a/src/database/migrations/mod.rs b/src/database/migrations/mod.rs index 1e6e55e..6eb7a39 100644 --- a/src/database/migrations/mod.rs +++ b/src/database/migrations/mod.rs @@ -12,6 +12,9 @@ mod m20250922_000001_add_grpc_hostname_to_servers; mod m20250923_000001_create_dns_providers_table; mod m20250929_000001_create_telegram_config_table; mod m20250929_000002_add_telegram_admin_to_users; +mod m20251018_000001_create_user_requests_table; +mod m20251018_000002_remove_unique_telegram_id; +mod m20251018_000003_add_language_to_user_requests; pub struct Migrator; @@ -31,6 +34,9 @@ impl MigratorTrait for Migrator { Box::new(m20250923_000001_create_dns_providers_table::Migration), Box::new(m20250929_000001_create_telegram_config_table::Migration), Box::new(m20250929_000002_add_telegram_admin_to_users::Migration), + Box::new(m20251018_000001_create_user_requests_table::Migration), + Box::new(m20251018_000002_remove_unique_telegram_id::Migration), + Box::new(m20251018_000003_add_language_to_user_requests::Migration), ] } } \ No newline at end of file diff --git a/src/database/repository/inbound_users.rs b/src/database/repository/inbound_users.rs index 5b14e97..ccafbbe 100644 --- a/src/database/repository/inbound_users.rs +++ b/src/database/repository/inbound_users.rs @@ -195,6 +195,7 @@ impl InboundUsersRepository { variable_values: server_inbound_entity.variable_values, server_name: server_entity.name, inbound_tag: server_inbound_entity.tag, + template_name: template_entity.name, }; Ok(Some(config)) diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index 4d7eff2..2e81045 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -7,6 +7,7 @@ pub mod server_inbound; pub mod user_access; pub mod inbound_users; pub mod telegram_config; +pub mod user_request; pub use user::UserRepository; pub use certificate::CertificateRepository; @@ -16,4 +17,5 @@ pub use server::ServerRepository; pub use server_inbound::ServerInboundRepository; pub use user_access::UserAccessRepository; pub use inbound_users::InboundUsersRepository; -pub use telegram_config::TelegramConfigRepository; \ No newline at end of file +pub use telegram_config::TelegramConfigRepository; +pub use user_request::UserRequestRepository; \ No newline at end of file diff --git a/src/database/repository/user.rs b/src/database/repository/user.rs index 30891a2..b5a34ed 100644 --- a/src/database/repository/user.rs +++ b/src/database/repository/user.rs @@ -126,15 +126,6 @@ impl UserRepository { Ok(count > 0) } - /// Get all Telegram admins - pub async fn get_telegram_admins(&self) -> Result> { - let admins = User::find() - .filter(Column::IsTelegramAdmin.eq(true)) - .order_by_desc(Column::CreatedAt) - .all(&self.db) - .await?; - Ok(admins) - } /// Set user as Telegram admin pub async fn set_telegram_admin(&self, user_id: Uuid, is_admin: bool) -> Result> { @@ -167,6 +158,16 @@ impl UserRepository { Ok(false) } } + + /// Get all Telegram admins + pub async fn get_telegram_admins(&self) -> Result> { + let admins = User::find() + .filter(Column::IsTelegramAdmin.eq(true)) + .filter(Column::TelegramId.is_not_null()) + .all(&self.db) + .await?; + Ok(admins) + } } #[cfg(test)] diff --git a/src/database/repository/user_request.rs b/src/database/repository/user_request.rs new file mode 100644 index 0000000..fce93c5 --- /dev/null +++ b/src/database/repository/user_request.rs @@ -0,0 +1,131 @@ +use anyhow::Result; +use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, DatabaseConnection, QueryOrder, PaginatorTrait, QuerySelect}; +use uuid::Uuid; +use crate::database::entities::user_request::{ + self, Model, ActiveModel, CreateUserRequestDto, UpdateUserRequestDto, RequestStatus +}; + +pub struct UserRequestRepository { + db: DatabaseConnection, +} + +impl UserRequestRepository { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } + + pub async fn find_all(&self, page: u64, per_page: u64) -> Result<(Vec, u64)> { + let paginator = user_request::Entity::find() + .order_by_desc(user_request::Column::CreatedAt) + .paginate(&self.db, per_page); + + let total = paginator.num_items().await?; + let items = paginator.fetch_page(page - 1).await?; + + Ok((items, total)) + } + + pub async fn find_pending(&self, page: u64, per_page: u64) -> Result<(Vec, u64)> { + let paginator = user_request::Entity::find() + .filter(user_request::Column::Status.eq("pending")) + .order_by_desc(user_request::Column::CreatedAt) + .paginate(&self.db, per_page); + + let total = paginator.num_items().await?; + let items = paginator.fetch_page(page - 1).await?; + + Ok((items, total)) + } + + pub async fn find_by_id(&self, id: Uuid) -> Result> { + let request = user_request::Entity::find_by_id(id) + .one(&self.db) + .await?; + Ok(request) + } + + pub async fn find_by_telegram_id(&self, telegram_id: i64) -> Result> { + let requests = user_request::Entity::find() + .filter(user_request::Column::TelegramId.eq(telegram_id)) + .order_by_desc(user_request::Column::CreatedAt) + .all(&self.db) + .await?; + Ok(requests) + } + + /// Find recent user requests (ordered by creation date) + pub async fn find_recent(&self, limit: u64) -> Result> { + let requests = user_request::Entity::find() + .order_by_desc(user_request::Column::CreatedAt) + .limit(limit) + .all(&self.db) + .await?; + Ok(requests) + } + + pub async fn find_pending_by_telegram_id(&self, telegram_id: i64) -> Result> { + let request = user_request::Entity::find() + .filter(user_request::Column::TelegramId.eq(telegram_id)) + .filter(user_request::Column::Status.eq("pending")) + .order_by_desc(user_request::Column::CreatedAt) + .one(&self.db) + .await?; + Ok(request) + } + + pub async fn create(&self, dto: CreateUserRequestDto) -> Result { + use sea_orm::ActiveModelTrait; + let active_model: ActiveModel = dto.into(); + let request = active_model.insert(&self.db).await?; + Ok(request) + } + + pub async fn update(&self, id: Uuid, dto: UpdateUserRequestDto, processed_by: Uuid) -> Result> { + let model = user_request::Entity::find_by_id(id) + .one(&self.db) + .await?; + + match model { + Some(model) => { + use sea_orm::ActiveModelTrait; + let active_model = model.apply_update(dto, processed_by); + let updated = active_model.update(&self.db).await?; + Ok(Some(updated)) + } + None => Ok(None), + } + } + + pub async fn approve(&self, id: Uuid, response_message: Option, processed_by: Uuid) -> Result> { + let dto = UpdateUserRequestDto { + status: Some(RequestStatus::Approved.as_str().to_string()), + response_message, + processed_by_user_id: None, + }; + self.update(id, dto, processed_by).await + } + + pub async fn decline(&self, id: Uuid, response_message: Option, processed_by: Uuid) -> Result> { + let dto = UpdateUserRequestDto { + status: Some(RequestStatus::Declined.as_str().to_string()), + response_message, + processed_by_user_id: None, + }; + self.update(id, dto, processed_by).await + } + + pub async fn delete(&self, id: Uuid) -> Result { + let result = user_request::Entity::delete_by_id(id) + .exec(&self.db) + .await?; + Ok(result.rows_affected > 0) + } + + pub async fn count_by_status(&self, status: RequestStatus) -> Result { + let count = user_request::Entity::find() + .filter(user_request::Column::Status.eq(status.as_str())) + .count(&self.db) + .await?; + Ok(count) + } +} \ No newline at end of file diff --git a/src/services/tasks.rs b/src/services/tasks.rs index 172729e..52751af 100644 --- a/src/services/tasks.rs +++ b/src/services/tasks.rs @@ -93,21 +93,50 @@ impl TaskScheduler { }); } - // Run initial sync on startup - let start_time = Utc::now(); - self.update_task_status("xray_sync", TaskState::Running, None); + // Run initial sync in background to avoid blocking startup + let db_initial = db.clone(); + let xray_service_initial = xray_service.clone(); + let task_status_initial = self.task_status.clone(); - match sync_xray_state(db.clone(), xray_service.clone()).await { - Ok(_) => { - let duration = (Utc::now() - start_time).num_milliseconds() as u64; - self.update_task_status("xray_sync", TaskState::Success, Some(duration)); - }, - Err(e) => { - let duration = (Utc::now() - start_time).num_milliseconds() as u64; - self.update_task_status_with_error("xray_sync", e.to_string(), Some(duration)); - error!("Initial xray sync failed: {}", e); + tokio::spawn(async move { + info!("Starting initial xray sync in background..."); + let start_time = Utc::now(); + + // Update status to running + { + let mut status = task_status_initial.write().unwrap(); + if let Some(task) = status.get_mut("xray_sync") { + task.status = TaskState::Running; + task.last_run = Some(start_time); + task.total_runs += 1; + } } - } + + match sync_xray_state(db_initial, xray_service_initial).await { + Ok(_) => { + let duration = (Utc::now() - start_time).num_milliseconds() as u64; + let mut status = task_status_initial.write().unwrap(); + if let Some(task) = status.get_mut("xray_sync") { + task.status = TaskState::Success; + task.success_count += 1; + task.last_duration_ms = Some(duration); + task.last_error = None; + } + info!("Initial xray sync completed successfully in {}ms", duration); + }, + Err(e) => { + let duration = (Utc::now() - start_time).num_milliseconds() as u64; + let mut status = task_status_initial.write().unwrap(); + if let Some(task) = status.get_mut("xray_sync") { + task.status = TaskState::Error; + task.error_count += 1; + task.last_duration_ms = Some(duration); + task.last_error = Some(e.to_string()); + } + error!("Initial xray sync failed: {}", e); + } + } + }); // Add synchronization task that runs every minute let db_clone = db.clone(); diff --git a/src/services/telegram/bot.rs b/src/services/telegram/bot.rs index 4dc7f6e..1d9c90a 100644 --- a/src/services/telegram/bot.rs +++ b/src/services/telegram/bot.rs @@ -2,7 +2,7 @@ use teloxide::{Bot, prelude::*}; use tokio::sync::oneshot; use crate::database::DatabaseManager; -use super::handlers; +use super::handlers::{self, Command}; /// Run the bot polling loop pub async fn run_polling( @@ -12,14 +12,21 @@ pub async fn run_polling( ) { tracing::info!("Starting Telegram bot polling..."); - let handler = Update::filter_message() + let handler = dptree::entry() .branch( - dptree::entry() - .filter_command::() - .endpoint(handlers::handle_command) + Update::filter_message() + .branch( + dptree::entry() + .filter_command::() + .endpoint(handlers::handle_command) + ) + .branch( + dptree::endpoint(handlers::handle_message) + ) ) .branch( - dptree::endpoint(handlers::handle_message) + Update::filter_callback_query() + .endpoint(handlers::handle_callback_query) ); let mut dispatcher = Dispatcher::builder(bot.clone(), handler) diff --git a/src/services/telegram/handlers.rs b/src/services/telegram/handlers.rs deleted file mode 100644 index 2f324d3..0000000 --- a/src/services/telegram/handlers.rs +++ /dev/null @@ -1,371 +0,0 @@ -use teloxide::{prelude::*, utils::command::BotCommands}; -use teloxide::types::Me; -use uuid::Uuid; - -use crate::database::DatabaseManager; -use crate::database::repository::UserRepository; -use crate::database::entities::user::CreateUserDto; - -/// Available bot commands -#[derive(BotCommands, Clone)] -#[command(rename_rule = "lowercase", description = "Available commands:")] -pub enum Command { - #[command(description = "Start the bot and register")] - Start, - #[command(description = "Show help message")] - Help, - #[command(description = "Show your status")] - Status, - #[command(description = "List available configurations")] - Configs, - // Admin commands - #[command(description = "[Admin] List all users")] - Users, - #[command(description = "[Admin] List all servers")] - Servers, - #[command(description = "[Admin] Show statistics")] - Stats, - #[command(description = "[Admin] Broadcast message", parse_with = "split")] - Broadcast { message: String }, -} - -/// Handle command messages -pub async fn handle_command( - bot: Bot, - msg: Message, - cmd: Command, - db: DatabaseManager, -) -> Result<(), Box> { - let chat_id = msg.chat.id; - let from = msg.from.as_ref().ok_or("No sender info")?; - let telegram_id = from.id.0 as i64; - - let user_repo = UserRepository::new(db.connection()); - - match cmd { - Command::Start => { - handle_start(bot, chat_id, telegram_id, from, &user_repo).await?; - } - Command::Help => { - bot.send_message(chat_id, Command::descriptions().to_string()).await?; - } - Command::Status => { - handle_status(bot, chat_id, telegram_id, &user_repo, &db).await?; - } - Command::Configs => { - handle_configs(bot, chat_id, telegram_id, &user_repo, &db).await?; - } - // Admin commands - Command::Users => { - if !user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { - bot.send_message(chat_id, "❌ You are not authorized to use this command").await?; - return Ok(()); - } - handle_users(bot, chat_id, &user_repo).await?; - } - Command::Servers => { - if !user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { - bot.send_message(chat_id, "❌ You are not authorized to use this command").await?; - return Ok(()); - } - handle_servers(bot, chat_id, &db).await?; - } - Command::Stats => { - if !user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { - bot.send_message(chat_id, "❌ You are not authorized to use this command").await?; - return Ok(()); - } - handle_stats(bot, chat_id, &db).await?; - } - Command::Broadcast { message } => { - if !user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { - bot.send_message(chat_id, "❌ You are not authorized to use this command").await?; - return Ok(()); - } - handle_broadcast(bot, chat_id, message, &user_repo).await?; - } - } - - Ok(()) -} - -/// Handle regular text messages -pub async fn handle_message( - bot: Bot, - msg: Message, - db: DatabaseManager, -) -> Result<(), Box> { - if let Some(text) = msg.text() { - if !text.starts_with('/') { - bot.send_message( - msg.chat.id, - "Please use /help to see available commands" - ).await?; - } - } - - Ok(()) -} - -/// Handle /start command -async fn handle_start( - bot: Bot, - chat_id: ChatId, - telegram_id: i64, - from: &teloxide::types::User, - user_repo: &UserRepository, -) -> Result<(), Box> { - // Check if user already exists - if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { - let message = format!( - "👋 Welcome back, {}!\n\n\ - You are already registered.\n\ - Use /help to see available commands.", - user.name - ); - bot.send_message(chat_id, message).await?; - } else { - // Create new user - let username = from.username.as_deref().unwrap_or("Unknown"); - let full_name = format!( - "{} {}", - from.first_name, - from.last_name.as_deref().unwrap_or("") - ).trim().to_string(); - - let dto = CreateUserDto { - name: if !full_name.is_empty() { full_name } else { username.to_string() }, - comment: Some(format!("Telegram user: @{}", username)), - telegram_id: Some(telegram_id), - is_telegram_admin: false, - }; - - match user_repo.create(dto).await { - Ok(user) => { - let message = format!( - "✅ Registration successful!\n\n\ - Name: {}\n\ - User ID: {}\n\n\ - Use /help to see available commands.", - user.name, user.id - ); - bot.send_message(chat_id, message).await?; - } - Err(e) => { - bot.send_message( - chat_id, - format!("❌ Registration failed: {}", e) - ).await?; - } - } - } - - Ok(()) -} - -/// Handle /status command -async fn handle_status( - bot: Bot, - chat_id: ChatId, - telegram_id: i64, - user_repo: &UserRepository, - db: &DatabaseManager, -) -> Result<(), Box> { - if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { - let server_inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection()); - let configs = server_inbound_repo.find_by_user_id(user.id).await.unwrap_or_default(); - - let admin_status = if user.is_telegram_admin { "Admin" } else { "User" }; - - let message = format!( - "📊 Your Status\n\n\ - Name: {}\n\ - User ID: {}\n\ - Role: {}\n\ - Active Configs: {}\n\ - Registered: {}", - user.name, - user.id, - admin_status, - configs.len(), - user.created_at.format("%Y-%m-%d %H:%M UTC") - ); - - bot.send_message(chat_id, message).await?; - } else { - bot.send_message( - chat_id, - "❌ You are not registered. Use /start to register." - ).await?; - } - - Ok(()) -} - -/// Handle /configs command -async fn handle_configs( - bot: Bot, - chat_id: ChatId, - telegram_id: i64, - user_repo: &UserRepository, - db: &DatabaseManager, -) -> Result<(), Box> { - if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { - let server_inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection()); - let configs = server_inbound_repo.find_by_user_id(user.id).await.unwrap_or_default(); - - if configs.is_empty() { - bot.send_message(chat_id, "You don't have any configurations yet.").await?; - } else { - let mut message = String::from("📋 Your Configurations:\n\n"); - - for (i, config) in configs.iter().enumerate() { - message.push_str(&format!( - "{}. {} (Port: {})\n", - i + 1, - config.tag, - config.port_override.unwrap_or(0) - )); - } - - bot.send_message(chat_id, message).await?; - } - } else { - bot.send_message( - chat_id, - "❌ You are not registered. Use /start to register." - ).await?; - } - - Ok(()) -} - -/// Handle /users command (admin only) -async fn handle_users( - bot: Bot, - chat_id: ChatId, - user_repo: &UserRepository, -) -> Result<(), Box> { - let users = user_repo.get_all(1, 100).await.unwrap_or_default(); - - if users.is_empty() { - bot.send_message(chat_id, "No users found.").await?; - } else { - let mut message = String::from("👥 Users:\n\n"); - - for (i, user) in users.iter().enumerate() { - let telegram_status = if user.telegram_id.is_some() { "✅" } else { "❌" }; - let admin_status = if user.is_telegram_admin { " (Admin)" } else { "" }; - - message.push_str(&format!( - "{}. {} {} {}{}\n", - i + 1, - user.name, - telegram_status, - user.id, - admin_status - )); - } - - bot.send_message(chat_id, message).await?; - } - - Ok(()) -} - -/// Handle /servers command (admin only) -async fn handle_servers( - bot: Bot, - chat_id: ChatId, - db: &DatabaseManager, -) -> Result<(), Box> { - let server_repo = crate::database::repository::ServerRepository::new(db.connection()); - let servers = server_repo.get_all().await.unwrap_or_default(); - - if servers.is_empty() { - bot.send_message(chat_id, "No servers found.").await?; - } else { - let mut message = String::from("🖥️ Servers:\n\n"); - - for (i, server) in servers.iter().enumerate() { - let status = if server.status == "active" { "✅" } else { "❌" }; - - message.push_str(&format!( - "{}. {} {} - {}\n", - i + 1, - status, - server.name, - server.hostname - )); - } - - bot.send_message(chat_id, message).await?; - } - - Ok(()) -} - -/// Handle /stats command (admin only) -async fn handle_stats( - bot: Bot, - chat_id: ChatId, - db: &DatabaseManager, -) -> Result<(), Box> { - let user_repo = UserRepository::new(db.connection()); - let server_repo = crate::database::repository::ServerRepository::new(db.connection()); - let inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection()); - - let user_count = user_repo.count().await.unwrap_or(0); - let server_count = server_repo.count().await.unwrap_or(0); - let inbound_count = inbound_repo.count().await.unwrap_or(0); - - let message = format!( - "📊 Statistics\n\n\ - Total Users: {}\n\ - Total Servers: {}\n\ - Total Inbounds: {}", - user_count, - server_count, - inbound_count - ); - - bot.send_message(chat_id, message).await?; - - Ok(()) -} - -/// Handle /broadcast command (admin only) -async fn handle_broadcast( - bot: Bot, - chat_id: ChatId, - message: String, - user_repo: &UserRepository, -) -> Result<(), Box> { - let users = user_repo.get_all(1, 1000).await.unwrap_or_default(); - let mut sent_count = 0; - let mut failed_count = 0; - - for user in users { - if let Some(telegram_id) = user.telegram_id { - match bot.send_message(ChatId(telegram_id), &message).await { - Ok(_) => sent_count += 1, - Err(e) => { - tracing::warn!("Failed to send broadcast to {}: {}", telegram_id, e); - failed_count += 1; - } - } - } - } - - bot.send_message( - chat_id, - format!( - "✅ Broadcast complete\n\ - Sent: {}\n\ - Failed: {}", - sent_count, failed_count - ) - ).await?; - - Ok(()) -} \ No newline at end of file diff --git a/src/services/telegram/handlers/admin.rs b/src/services/telegram/handlers/admin.rs new file mode 100644 index 0000000..f3268e9 --- /dev/null +++ b/src/services/telegram/handlers/admin.rs @@ -0,0 +1,723 @@ +use teloxide::{prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery}}; +use uuid::Uuid; + +use crate::database::DatabaseManager; +use crate::database::repository::{UserRepository, UserRequestRepository}; +use crate::database::entities::user_request::RequestStatus; +use super::super::localization::{LocalizationService, Language}; +use super::types::get_selected_servers; + +/// Handle admin requests edit (show list of recent requests) +pub async fn handle_admin_requests_edit( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, +) -> Result<(), Box> { + let admin_telegram_id = q.from.id.0 as i64; + let lang = Language::English; // Default admin language + let l10n = LocalizationService::new(); + let chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + let user_repo = UserRepository::new(db.connection()); + let request_repo = UserRequestRepository::new(db.connection().clone()); + + // Check if user is admin + if !user_repo.is_telegram_id_admin(admin_telegram_id).await.unwrap_or(false) { + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "unauthorized")) + .await?; + return Ok(()); + } + + // Get recent requests (last 10) + let recent_requests = request_repo.find_recent(10).await.unwrap_or_default(); + + if recent_requests.is_empty() { + // Edit message to show no requests + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang.clone(), "no_pending_requests")) + .reply_markup(InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], + ])) + .await?; + } + } + bot.answer_callback_query(q.id.clone()).await?; + return Ok(()); + } + + // Build message with request list + let mut message_lines = vec!["📋 Recent Access Requests\n".to_string()]; + let mut keyboard_buttons = vec![]; + + for request in &recent_requests { + let status_emoji = match request.status.as_str() { + "pending" => "⏳", + "approved" => "✅", + "declined" => "❌", + _ => "❓" + }; + + let username = request.telegram_username.as_deref().unwrap_or("unknown"); + let processed_info = if let Some(processed_by_id) = request.processed_by_user_id { + if let Ok(Some(admin)) = user_repo.get_by_id(processed_by_id).await { + format!(" by {}", admin.name) + } else { + String::new() + } + } else { + String::new() + }; + + let button_text = format!("{} {} @{}{}", status_emoji, request.get_full_name(), username, processed_info); + + keyboard_buttons.push(vec![ + InlineKeyboardButton::callback(button_text, format!("view_request:{}", request.id)) + ]); + } + + // Add back button + keyboard_buttons.push(vec![ + InlineKeyboardButton::callback(l10n.get(lang, "back"), "back") + ]); + + let message = message_lines.join("\n"); + + // Edit the existing message instead of sending a new one + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(keyboard_buttons)) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + + Ok(()) +} + +/// Handle approve request +pub async fn handle_approve_request( + bot: Bot, + q: &CallbackQuery, + request_id: &str, + db: &DatabaseManager, +) -> Result<(), Box> { + let admin_telegram_id = q.from.id.0 as i64; + let lang = Language::English; // Default admin language + let l10n = LocalizationService::new(); + let _chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + let user_repo = UserRepository::new(db.connection()); + let request_repo = UserRequestRepository::new(db.connection().clone()); + + // Get admin user + let admin = user_repo.get_by_telegram_id(admin_telegram_id).await + .unwrap_or(None) + .ok_or(l10n.get(lang.clone(), "admin_not_found"))?; + + // Parse request ID + let request_id = Uuid::parse_str(request_id).map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?; + + // Get the request + let request = request_repo.find_by_id(request_id).await + .unwrap_or(None) + .ok_or(l10n.get(lang.clone(), "request_not_found"))?; + + // Check if request is already processed + if request.status != "pending" { + bot.answer_callback_query(q.id.clone()) + .text("This request has already been processed") + .await?; + return Ok(()); + } + + // Create user account + let username = request.telegram_username.as_deref().unwrap_or("Unknown"); + let dto = crate::database::entities::user::CreateUserDto { + name: request.get_full_name(), + comment: Some(format!("Telegram user: @{}", username)), + telegram_id: Some(request.telegram_id), + is_telegram_admin: false, + }; + + match user_repo.create(dto).await { + Ok(new_user) => { + // Approve the request + request_repo.approve( + request_id, + Some(format!("Approved by {}", admin.name)), + admin.id + ).await?; + + // Update the callback message to show approval status and server selection + if let Some(message) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message { + if let Some(text) = msg.text() { + let updated_text = format!("{}\n\n✅ APPROVED by {}\n\n📋 Select servers to grant access:", text, admin.name); + let request_id_compact = request_id.to_string().replace("-", ""); + let callback_data = format!("s:{}", request_id_compact); + tracing::info!("Callback data length: {} bytes, data: '{}'", callback_data.len(), callback_data); + let server_selection_keyboard = InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback("🖥️ Select Servers", callback_data)], + vec![InlineKeyboardButton::callback("📋 All Requests", "back_to_requests")], + ]); + + let _ = bot.edit_message_text(msg.chat.id, msg.id, updated_text) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(server_selection_keyboard) + .await; + } + } + } + + // Notify the user using their saved language preference + let user_lang = Language::from_telegram_code(Some(&request.get_language())); + let user_message = l10n.format(user_lang, "request_approved_notification", &[("user_id", &new_user.id.to_string())]); + + bot.send_message(ChatId(request.telegram_id), user_message) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "request_approved_admin")) + .await?; + } + Err(e) => { + tracing::error!("Failed to create user during approval: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.format(lang.clone(), "user_creation_failed", &[("error", &e.to_string())])) + .await?; + } + } + + Ok(()) +} + +/// Handle decline request +pub async fn handle_decline_request( + bot: Bot, + q: &CallbackQuery, + request_id: &str, + db: &DatabaseManager, +) -> Result<(), Box> { + let admin_telegram_id = q.from.id.0 as i64; + let lang = Language::English; // Default admin language + let l10n = LocalizationService::new(); + + let user_repo = UserRepository::new(db.connection()); + let request_repo = UserRequestRepository::new(db.connection().clone()); + + // Get admin user + let admin = user_repo.get_by_telegram_id(admin_telegram_id).await + .unwrap_or(None) + .ok_or(l10n.get(lang.clone(), "admin_not_found"))?; + + // Parse request ID + let request_id = Uuid::parse_str(request_id).map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?; + + // Get the request + let request = request_repo.find_by_id(request_id).await + .unwrap_or(None) + .ok_or(l10n.get(lang.clone(), "request_not_found"))?; + + // Check if request is already processed + if request.status != "pending" { + bot.answer_callback_query(q.id.clone()) + .text("This request has already been processed") + .await?; + return Ok(()); + } + + // Decline the request + request_repo.decline( + request_id, + Some(format!("Declined by {}", admin.name)), + admin.id + ).await?; + + // Update the callback message to show decline status + if let Some(message) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message { + if let Some(text) = msg.text() { + let updated_text = format!("{}\n\n❌ DECLINED by {}", text, admin.name); + let back_keyboard = InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback("📋 All Requests", "back_to_requests")], + ]); + + let _ = bot.edit_message_text(msg.chat.id, msg.id, updated_text) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(back_keyboard) + .await; + } + } + } + + // Notify the user using their saved language preference + let user_lang = Language::from_telegram_code(Some(&request.get_language())); + let user_message = l10n.get(user_lang, "request_declined_notification"); + + bot.send_message(ChatId(request.telegram_id), user_message) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "request_declined_admin")) + .await?; + + Ok(()) +} + +/// Handle view request details +pub async fn handle_view_request( + bot: Bot, + q: &CallbackQuery, + request_id: &str, + db: &DatabaseManager, +) -> Result<(), Box> { + let lang = Language::English; // Default admin language + let l10n = LocalizationService::new(); + let chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + let request_repo = UserRequestRepository::new(db.connection().clone()); + let user_repo = UserRepository::new(db.connection()); + + // Parse request ID + let request_id = Uuid::parse_str(request_id).map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?; + + // Get the request + let request = request_repo.find_by_id(request_id).await + .unwrap_or(None) + .ok_or(l10n.get(lang.clone(), "request_not_found"))?; + + // Get processed by admin info + let processed_by = if let Some(processed_by_id) = request.processed_by_user_id { + if let Ok(Some(admin)) = user_repo.get_by_id(processed_by_id).await { + format!("\n👤 Processed by: {}", admin.name) + } else { + String::new() + } + } else { + String::new() + }; + + let processed_at = if let Some(processed_at) = request.processed_at { + format!("\n⏰ Processed at: {}", processed_at.format("%Y-%m-%d %H:%M UTC")) + } else { + String::new() + }; + + let status_emoji = match request.status.as_str() { + "pending" => "⏳", + "approved" => "✅", + "declined" => "❌", + _ => "❓" + }; + + let message = format!( + "📋 Access Request Details\n\n\ + 👤 Name: {}\n\ + 🆔 Telegram: {}\n\ + 🌍 Language: {}\n\ + 📅 Requested: {}\n\ + {} Status: {}{}{}\n\n\ + 💬 Message: {}", + request.get_full_name(), + request.get_telegram_link(), + request.get_language().to_uppercase(), + request.created_at.format("%Y-%m-%d %H:%M UTC"), + status_emoji, + request.status.to_uppercase(), + processed_by, + processed_at, + request.request_message.as_deref().unwrap_or("No message") + ); + + // Create keyboard based on request status + let keyboard = if request.status == "pending" { + InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback(l10n.get(lang.clone(), "approve"), format!("approve:{}", request.id)), + InlineKeyboardButton::callback(l10n.get(lang.clone(), "decline"), format!("decline:{}", request.id)), + ], + vec![ + InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back_to_requests"), + InlineKeyboardButton::callback("🏠 Menu", "back"), + ], + ]) + } else { + InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back_to_requests"), + InlineKeyboardButton::callback("🏠 Menu", "back"), + ], + ]) + }; + + // Edit the existing message instead of sending a new one + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + + Ok(()) +} + +/// Handle /stats command (admin only) +pub async fn handle_stats( + bot: Bot, + chat_id: ChatId, + db: &DatabaseManager, +) -> Result<(), Box> { + let lang = Language::English; // Default admin language + let l10n = LocalizationService::new(); + + let user_repo = UserRepository::new(db.connection()); + let server_repo = crate::database::repository::ServerRepository::new(db.connection()); + let inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection()); + let request_repo = UserRequestRepository::new(db.connection().clone()); + + let user_count = user_repo.count().await.unwrap_or(0); + let server_count = server_repo.count().await.unwrap_or(0); + let inbound_count = inbound_repo.count().await.unwrap_or(0); + let pending_requests = request_repo.count_by_status(RequestStatus::Pending).await.unwrap_or(0); + + let message = l10n.format(lang, "statistics", &[ + ("users", &user_count.to_string()), + ("servers", &server_count.to_string()), + ("inbounds", &inbound_count.to_string()), + ("pending", &pending_requests.to_string()) + ]); + + bot.send_message(chat_id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + + Ok(()) +} + +/// Handle /broadcast command (admin only) +pub async fn handle_broadcast( + bot: Bot, + chat_id: ChatId, + message: String, + user_repo: &UserRepository, +) -> Result<(), Box> { + let users = user_repo.get_all(1, 1000).await.unwrap_or_default(); + let mut sent_count = 0; + let mut failed_count = 0; + + for user in users { + if let Some(telegram_id) = user.telegram_id { + match bot.send_message(ChatId(telegram_id), &message).await { + Ok(_) => sent_count += 1, + Err(_) => failed_count += 1, + } + } + } + + let lang = Language::English; // Default admin language + let l10n = LocalizationService::new(); + + let result_message = l10n.format(lang, "broadcast_complete", &[ + ("sent", &sent_count.to_string()), + ("failed", &failed_count.to_string()) + ]); + + bot.send_message(chat_id, result_message).await?; + + Ok(()) +} + +/// Handle server selection after approval +pub async fn handle_select_server_access( + bot: Bot, + q: &CallbackQuery, + request_id: &str, + db: &DatabaseManager, +) -> Result<(), Box> { + let lang = Language::English; // Default admin language + let _l10n = LocalizationService::new(); + let chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + let server_repo = crate::database::repository::ServerRepository::new(db.connection()); + + // Get all active servers + let servers = server_repo.find_all().await.unwrap_or_default(); + + if servers.is_empty() { + bot.answer_callback_query(q.id.clone()) + .text("No servers available") + .await?; + return Ok(()); + } + + // Initialize selected servers for this request (empty initially) + { + let mut selected = get_selected_servers().lock().unwrap(); + selected.insert(request_id.to_string(), Vec::new()); + } + + // Build keyboard with server toggle buttons + let mut keyboard_buttons = vec![]; + let selected_servers = { + let selected = get_selected_servers().lock().unwrap(); + selected.get(request_id).cloned().unwrap_or_default() + }; + + for server in &servers { + let is_selected = selected_servers.contains(&server.id.to_string()); + let button_text = if is_selected { + format!("✅ {}", server.name) + } else { + format!("⬜ {}", server.name) + }; + + keyboard_buttons.push(vec![ + InlineKeyboardButton::callback( + button_text, + format!("t:{}:{}", request_id.to_string().replace("-", ""), server.id.to_string().replace("-", "")) + ) + ]); + } + + // Add apply and back buttons + keyboard_buttons.push(vec![ + InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", request_id.to_string().replace("-", ""))), + InlineKeyboardButton::callback("🔙 Back", "back_to_requests"), + ]); + + let message = format!("🖥️ Select Servers for Access\n\nChoose which servers to grant access to the approved user:"); + + // Edit the existing message + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(keyboard_buttons)) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + Ok(()) +} + +/// Handle toggling server selection +pub async fn handle_toggle_server( + bot: Bot, + q: &CallbackQuery, + request_id: &str, + server_id: &str, + db: &DatabaseManager, +) -> Result<(), Box> { + let chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + // Toggle server selection + { + let mut selected = get_selected_servers().lock().unwrap(); + let server_list = selected.entry(request_id.to_string()).or_insert_with(Vec::new); + + if let Some(pos) = server_list.iter().position(|x| x == server_id) { + server_list.remove(pos); + } else { + server_list.push(server_id.to_string()); + } + } + + // Rebuild the keyboard with updated selection + let server_repo = crate::database::repository::ServerRepository::new(db.connection()); + let servers = server_repo.find_all().await.unwrap_or_default(); + + let mut keyboard_buttons = vec![]; + let selected_servers = { + let selected = get_selected_servers().lock().unwrap(); + selected.get(request_id).cloned().unwrap_or_default() + }; + + for server in &servers { + let is_selected = selected_servers.contains(&server.id.to_string()); + let button_text = if is_selected { + format!("✅ {}", server.name) + } else { + format!("⬜ {}", server.name) + }; + + keyboard_buttons.push(vec![ + InlineKeyboardButton::callback( + button_text, + format!("t:{}:{}", request_id.to_string().replace("-", ""), server.id.to_string().replace("-", "")) + ) + ]); + } + + // Add apply and back buttons + keyboard_buttons.push(vec![ + InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", request_id.to_string().replace("-", ""))), + InlineKeyboardButton::callback("🔙 Back", "back_to_requests"), + ]); + + let selected_count = selected_servers.len(); + let message = format!("🖥️ Select Servers for Access\n\nChoose which servers to grant access to the approved user:\n\n📊 Selected: {} server{}", + selected_count, if selected_count == 1 { "" } else { "s" }); + + // Edit the existing message + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(keyboard_buttons)) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + Ok(()) +} + +/// Handle applying server access +pub async fn handle_apply_server_access( + bot: Bot, + q: &CallbackQuery, + request_id: &str, + db: &DatabaseManager, +) -> Result<(), Box> { + let lang = Language::English; // Default admin language + let _l10n = LocalizationService::new(); + let chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + // Get selected servers + let selected_server_ids = { + let selected = get_selected_servers().lock().unwrap(); + selected.get(request_id).cloned().unwrap_or_default() + }; + + if selected_server_ids.is_empty() { + bot.answer_callback_query(q.id.clone()) + .text("No servers selected") + .await?; + return Ok(()); + } + + let request_repo = UserRequestRepository::new(db.connection().clone()); + let user_repo = UserRepository::new(db.connection()); + let server_repo = crate::database::repository::ServerRepository::new(db.connection()); + let inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection().clone()); + let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone()); + + // Parse request ID and get request + let request_uuid = Uuid::parse_str(request_id).map_err(|_| "Invalid request ID")?; + let request = request_repo.find_by_id(request_uuid).await + .unwrap_or(None) + .ok_or("Request not found")?; + + // Get user + let user = user_repo.get_by_telegram_id(request.telegram_id).await + .unwrap_or(None) + .ok_or("User not found")?; + + let mut granted_servers = Vec::new(); + let mut total_inbounds = 0; + + // Grant access to all inbounds on selected servers + for server_id_str in &selected_server_ids { + if let Ok(server_id) = Uuid::parse_str(server_id_str) { + // Get server info + if let Ok(Some(server)) = server_repo.find_by_id(server_id).await { + granted_servers.push(server.name.clone()); + + // Get all inbounds for this server + if let Ok(inbounds) = inbound_repo.find_by_server_id(server_id).await { + for inbound in inbounds { + // Check if user already has access to this inbound + if !inbound_users_repo.user_has_access_to_inbound(user.id, inbound.id).await.unwrap_or(false) { + // Create inbound user access + let dto = crate::database::entities::inbound_users::CreateInboundUserDto { + user_id: user.id, + server_inbound_id: inbound.id, + level: Some(0), + }; + + if let Ok(_) = inbound_users_repo.create(dto).await { + total_inbounds += 1; + } + } + } + } + } + } + } + + // Clean up selected servers storage + { + let mut selected = get_selected_servers().lock().unwrap(); + selected.remove(request_id); + } + + // Update message with success + let message = format!( + "✅ Server Access Granted\n\nUser: {}\nServers: {}\nTotal inbounds: {}\n\n✅ Access has been successfully granted!", + user.name, + granted_servers.join(", "), + total_inbounds + ); + + let back_keyboard = InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback("📋 All Requests", "back_to_requests")], + ]); + + // Edit the existing message + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(back_keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()) + .text(format!("Access granted to {} servers", granted_servers.len())) + .await?; + + Ok(()) +} \ No newline at end of file diff --git a/src/services/telegram/handlers/mod.rs b/src/services/telegram/handlers/mod.rs new file mode 100644 index 0000000..80d149d --- /dev/null +++ b/src/services/telegram/handlers/mod.rs @@ -0,0 +1,153 @@ +pub mod admin; +pub mod user; +pub mod types; + +// Re-export main handler functions for easier access +pub use admin::*; +pub use user::*; +pub use types::*; + +use teloxide::{prelude::*, types::CallbackQuery}; +use crate::database::DatabaseManager; + +/// Handle bot commands +pub async fn handle_command( + bot: Bot, + msg: Message, + cmd: Command, + db: DatabaseManager, +) -> Result<(), Box> { + let chat_id = msg.chat.id; + let from = &msg.from.ok_or("No user info")?; + let telegram_id = from.id.0 as i64; + let user_repo = crate::database::repository::UserRepository::new(db.connection()); + + match cmd { + Command::Start => { + handle_start(bot, chat_id, telegram_id, from, &user_repo, &db).await?; + } + Command::Requests => { + // Check if user is admin + if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + // Create a fake callback query for admin requests + // This is a workaround since the admin_requests function expects a callback query + // In practice, we could refactor this to not need a callback query + tracing::info!("Admin {} requested to view requests", telegram_id); + + let message = "📋 Use the inline keyboard to view recent requests."; + let keyboard = teloxide::types::InlineKeyboardMarkup::new(vec![ + vec![teloxide::types::InlineKeyboardButton::callback("📋 Recent Requests", "admin_requests")], + ]); + + bot.send_message(chat_id, message) + .reply_markup(keyboard) + .await?; + } else { + let lang = get_user_language(from); + let l10n = super::localization::LocalizationService::new(); + bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?; + } + } + Command::Stats => { + // Check if user is admin + if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + handle_stats(bot, chat_id, &db).await?; + } else { + let lang = get_user_language(from); + let l10n = super::localization::LocalizationService::new(); + bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?; + } + } + Command::Broadcast { message } => { + // Check if user is admin + if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + handle_broadcast(bot, chat_id, message, &user_repo).await?; + } else { + let lang = get_user_language(from); + let l10n = super::localization::LocalizationService::new(); + bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?; + } + } + } + + Ok(()) +} + +/// Handle regular messages (fallback) +pub async fn handle_message( + bot: Bot, + msg: Message, + db: DatabaseManager, +) -> Result<(), Box> { + let chat_id = msg.chat.id; + let from = msg.from.as_ref().ok_or("No user info")?; + let telegram_id = from.id.0 as i64; + let user_repo = crate::database::repository::UserRepository::new(db.connection()); + + // For non-command messages, just show the start menu + handle_start(bot, chat_id, telegram_id, from, &user_repo, &db).await?; + + Ok(()) +} + +/// Handle callback queries from inline keyboards +pub async fn handle_callback_query( + bot: Bot, + q: CallbackQuery, + db: DatabaseManager, +) -> Result<(), Box> { + if let Some(data) = &q.data { + if let Some(callback_data) = CallbackData::parse(data) { + match callback_data { + CallbackData::RequestAccess => { + handle_request_access(bot, &q, &db).await?; + } + CallbackData::MyConfigs => { + handle_my_configs_edit(bot, &q, &db).await?; + } + CallbackData::Support => { + handle_support(bot, &q).await?; + } + CallbackData::AdminRequests => { + handle_admin_requests_edit(bot, &q, &db).await?; + } + CallbackData::ApproveRequest(request_id) => { + handle_approve_request(bot, &q, &request_id, &db).await?; + } + CallbackData::DeclineRequest(request_id) => { + handle_decline_request(bot, &q, &request_id, &db).await?; + } + CallbackData::ViewRequest(request_id) => { + handle_view_request(bot, &q, &request_id, &db).await?; + } + CallbackData::ShowServerConfigs(encoded_server_name) => { + handle_show_server_configs(bot, &q, &encoded_server_name, &db).await?; + } + CallbackData::SelectServerAccess(request_id) => { + handle_select_server_access(bot, &q, &request_id, &db).await?; + } + CallbackData::ToggleServer(request_id, server_id) => { + handle_toggle_server(bot, &q, &request_id, &server_id, &db).await?; + } + CallbackData::ApplyServerAccess(request_id) => { + handle_apply_server_access(bot, &q, &request_id, &db).await?; + } + CallbackData::Back => { + // Back to main menu - edit the existing message + handle_start_edit(bot, &q, &db).await?; + } + CallbackData::BackToConfigs => { + handle_my_configs_edit(bot, &q, &db).await?; + } + CallbackData::BackToRequests => { + handle_admin_requests_edit(bot, &q, &db).await?; + } + } + } else { + tracing::warn!("Unknown callback data: {}", data); + bot.answer_callback_query(q.id.clone()).await?; + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/services/telegram/handlers/types.rs b/src/services/telegram/handlers/types.rs new file mode 100644 index 0000000..af076cc --- /dev/null +++ b/src/services/telegram/handlers/types.rs @@ -0,0 +1,137 @@ +use teloxide::utils::command::BotCommands; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, User}; + +use super::super::localization::{LocalizationService, Language}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Available bot commands - keeping only admin commands +#[derive(BotCommands, Clone)] +#[command(rename_rule = "lowercase", description = "Admin commands:")] +pub enum Command { + #[command(description = "Start the bot")] + Start, + #[command(description = "[Admin] Manage user requests")] + Requests, + #[command(description = "[Admin] Show statistics")] + Stats, + #[command(description = "[Admin] Broadcast message", parse_with = "split")] + Broadcast { message: String }, +} + +/// Callback data for inline keyboard buttons +#[derive(Debug, Clone)] +pub enum CallbackData { + RequestAccess, + MyConfigs, + Support, + AdminRequests, + ApproveRequest(String), // request_id + DeclineRequest(String), // request_id + ViewRequest(String), // request_id + ShowServerConfigs(String), // server_name encoded + Back, + BackToConfigs, // Back to configs list from server view + BackToRequests, // Back to requests list from request view + SelectServerAccess(String), // request_id - show server selection after approval + ToggleServer(String, String), // request_id, server_id - toggle server selection + ApplyServerAccess(String), // request_id - apply selected servers +} + +impl CallbackData { + pub fn parse(data: &str) -> Option { + match data { + "request_access" => Some(CallbackData::RequestAccess), + "my_configs" => Some(CallbackData::MyConfigs), + "support" => Some(CallbackData::Support), + "admin_requests" => Some(CallbackData::AdminRequests), + "back" => Some(CallbackData::Back), + "back_to_configs" => Some(CallbackData::BackToConfigs), + "back_to_requests" => Some(CallbackData::BackToRequests), + _ => { + if let Some(id) = data.strip_prefix("approve:") { + Some(CallbackData::ApproveRequest(id.to_string())) + } else if let Some(id) = data.strip_prefix("decline:") { + Some(CallbackData::DeclineRequest(id.to_string())) + } else if let Some(id) = data.strip_prefix("view_request:") { + Some(CallbackData::ViewRequest(id.to_string())) + } else if let Some(server_name) = data.strip_prefix("server_configs:") { + Some(CallbackData::ShowServerConfigs(server_name.to_string())) + } else if let Some(id) = data.strip_prefix("s:") { + restore_uuid(id).map(CallbackData::SelectServerAccess) + } else if let Some(rest) = data.strip_prefix("t:") { + let parts: Vec<&str> = rest.split(':').collect(); + if parts.len() == 2 { + if let (Some(request_id), Some(server_id)) = (restore_uuid(parts[0]), restore_uuid(parts[1])) { + Some(CallbackData::ToggleServer(request_id, server_id)) + } else { + None + } + } else { + None + } + } else if let Some(id) = data.strip_prefix("a:") { + restore_uuid(id).map(CallbackData::ApplyServerAccess) + } else { + None + } + } + } + } +} + +// Global storage for selected servers per request +static SELECTED_SERVERS: OnceLock>>>> = OnceLock::new(); + +pub fn get_selected_servers() -> &'static Arc>>> { + SELECTED_SERVERS.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) +} + +/// Helper function to get user language from Telegram user data +pub fn get_user_language(user: &User) -> Language { + Language::from_telegram_code(user.language_code.as_deref()) +} + +/// Main keyboard for registered users +pub fn get_main_keyboard(is_admin: bool, lang: Language) -> InlineKeyboardMarkup { + let l10n = LocalizationService::new(); + + let mut keyboard = vec![ + vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "my_configs"), "my_configs")], + vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "support"), "support")], + ]; + + if is_admin { + keyboard.push(vec![InlineKeyboardButton::callback(l10n.get(lang, "user_requests"), "admin_requests")]); + } + + InlineKeyboardMarkup::new(keyboard) +} + +/// Keyboard for new users +pub fn get_new_user_keyboard(lang: Language) -> InlineKeyboardMarkup { + let l10n = LocalizationService::new(); + + InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback(l10n.get(lang, "get_vpn_access"), "request_access")], + ]) +} + +/// Restore UUID from compact format (without dashes) +fn restore_uuid(compact: &str) -> Option { + if compact.len() != 32 { + return None; + } + + // Insert dashes at proper positions for UUID format + let uuid_str = format!( + "{}-{}-{}-{}-{}", + &compact[0..8], + &compact[8..12], + &compact[12..16], + &compact[16..20], + &compact[20..32] + ); + + Some(uuid_str) +} \ No newline at end of file diff --git a/src/services/telegram/handlers/user.rs b/src/services/telegram/handlers/user.rs new file mode 100644 index 0000000..caf8347 --- /dev/null +++ b/src/services/telegram/handlers/user.rs @@ -0,0 +1,669 @@ +use teloxide::{prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup}}; +use base64::{Engine, engine::general_purpose}; + +use crate::database::DatabaseManager; +use crate::database::repository::{UserRepository, UserRequestRepository}; +use crate::database::entities::user_request::{CreateUserRequestDto, RequestStatus}; +use super::super::localization::{LocalizationService, Language}; +use super::types::{get_user_language, get_main_keyboard, get_new_user_keyboard}; + +/// Handle start command and main menu +pub async fn handle_start( + bot: Bot, + chat_id: ChatId, + telegram_id: i64, + from: &teloxide::types::User, + user_repo: &UserRepository, + db: &DatabaseManager, +) -> Result<(), Box> { + handle_start_impl(bot, chat_id, telegram_id, from, user_repo, db, None, None).await +} + +/// Handle start with message editing support +pub async fn handle_start_edit( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, +) -> Result<(), Box> { + let from = &q.from; + let telegram_id = from.id.0 as i64; + let user_repo = UserRepository::new(db.connection()); + + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + let chat_id = regular_msg.chat.id; + handle_start_impl( + bot.clone(), + chat_id, + telegram_id, + from, + &user_repo, + db, + Some(regular_msg.id), + Some(q.id.clone()) + ).await?; + } + } + + Ok(()) +} + +/// Internal implementation of handle_start with optional message editing +async fn handle_start_impl( + bot: Bot, + chat_id: ChatId, + telegram_id: i64, + from: &teloxide::types::User, + user_repo: &UserRepository, + db: &DatabaseManager, + edit_message_id: Option, + callback_query_id: Option, +) -> Result<(), Box> { + let lang = get_user_language(from); + let l10n = LocalizationService::new(); + + // Check if user exists in our database + match user_repo.get_by_telegram_id(telegram_id).await { + Ok(Some(user)) => { + // Check if user is admin + let is_admin = user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false); + + // Check if user has any pending requests + let request_repo = UserRequestRepository::new(db.connection().clone()); + + // Check for existing requests + if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await { + if let Some(latest_request) = existing_requests.into_iter() + .filter(|r| r.status == "pending" || r.status == "approved" || r.status == "declined") + .max_by_key(|r| r.created_at) { + + match latest_request.status.as_str() { + "pending" => { + let message = l10n.format(lang.clone(), "request_pending", &[ + ("status", "⏳ pending"), + ("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) + ]); + + let keyboard = get_new_user_keyboard(lang); + + if let Some(msg_id) = edit_message_id { + bot.edit_message_text(chat_id, msg_id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + + if let Some(cb_id) = callback_query_id { + bot.answer_callback_query(cb_id).await?; + } + } else { + bot.send_message(chat_id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + return Ok(()); + } + "declined" => { + let message = l10n.format(lang.clone(), "request_pending", &[ + ("status", &l10n.get(lang.clone(), "request_declined_status")), + ("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) + ]); + + let keyboard = get_new_user_keyboard(lang); + + if let Some(msg_id) = edit_message_id { + bot.edit_message_text(chat_id, msg_id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + + if let Some(cb_id) = callback_query_id { + bot.answer_callback_query(cb_id).await?; + } + } else { + bot.send_message(chat_id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + return Ok(()); + } + _ => {} // approved - continue with normal flow + } + } + } + + // Existing user - show main menu + let message = l10n.format(lang.clone(), "welcome_back", &[("name", &user.name)]); + let keyboard = get_main_keyboard(is_admin, lang); + + if let Some(msg_id) = edit_message_id { + bot.edit_message_text(chat_id, msg_id, message) + .reply_markup(keyboard) + .await?; + + if let Some(cb_id) = callback_query_id { + bot.answer_callback_query(cb_id).await?; + } + } else { + bot.send_message(chat_id, message) + .reply_markup(keyboard) + .await?; + } + } + Ok(None) => { + // New user - show access request + let username = from.username.as_deref().unwrap_or("Unknown"); + let message = l10n.format(lang.clone(), "welcome_new_user", &[("username", username)]); + let keyboard = get_new_user_keyboard(lang); + + if let Some(msg_id) = edit_message_id { + bot.edit_message_text(chat_id, msg_id, message) + .reply_markup(keyboard) + .await?; + + if let Some(cb_id) = callback_query_id { + bot.answer_callback_query(cb_id).await?; + } + } else { + bot.send_message(chat_id, message) + .reply_markup(keyboard) + .await?; + } + } + Err(e) => { + tracing::error!("Database error: {}", e); + bot.send_message(chat_id, "Database error occurred").await?; + } + } + + Ok(()) +} + +/// Handle access request +pub async fn handle_request_access( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, +) -> Result<(), Box> { + let from = &q.from; + let lang = get_user_language(from); + let l10n = LocalizationService::new(); + let telegram_id = from.id.0 as i64; + let chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + let user_repo = UserRepository::new(db.connection()); + let request_repo = UserRequestRepository::new(db.connection().clone()); + + // Check if user already exists + if let Some(_) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "already_approved")) + .await?; + return Ok(()); + } + + // Check for existing requests + if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await { + if let Some(latest_request) = existing_requests.iter() + .filter(|r| r.status == "pending") + .max_by_key(|r| r.created_at) { + + // Show pending status in the message instead of just an alert + let message = l10n.format(lang.clone(), "request_pending", &[ + ("status", "⏳ pending"), + ("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) + ]); + + if let Some(message_ref) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message_ref { + let _ = bot.edit_message_text(chat_id, msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], + ])) + .await; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + return Ok(()); + } + + // Check for declined requests - allow new request after decline + let _has_declined = existing_requests.iter() + .any(|r| r.status == "declined"); + } + + // Create new access request + let dto = CreateUserRequestDto { + telegram_id, + telegram_first_name: Some(from.first_name.clone()), + telegram_last_name: from.last_name.clone(), + telegram_username: from.username.clone(), + request_message: Some("Access request via Telegram bot".to_string()), + language: lang.code().to_string(), + }; + + match request_repo.create(dto).await { + Ok(request) => { + // Edit message to show success + if let Some(message) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message { + let _ = bot.edit_message_text(chat_id, msg.id, l10n.get(lang.clone(), "request_submitted")) + .reply_markup(InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], + ])) + .await; + } + } + + // Notify admins + notify_admins_new_request(&bot, &request, db).await?; + + bot.answer_callback_query(q.id.clone()).await?; + } + Err(e) => { + tracing::error!("Failed to create request: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.format(lang, "request_submit_failed", &[("error", &e.to_string())])) + .await?; + } + } + + Ok(()) +} + +/// Handle my configs with message editing +pub async fn handle_my_configs_edit( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, +) -> Result<(), Box> { + let from = &q.from; + let lang = get_user_language(from); + let l10n = LocalizationService::new(); + let telegram_id = from.id.0 as i64; + let chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + let user_repo = UserRepository::new(db.connection()); + let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone()); + let uri_service = crate::services::UriGeneratorService::new(); + + if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { + // Get all active inbound users for this user + let inbound_users = inbound_users_repo.find_by_user_id(user.id).await.unwrap_or_default(); + + if inbound_users.is_empty() { + // Edit message to show no configs available + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang.clone(), "no_configs_available")) + .reply_markup(InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], + ])) + .await?; + } + } + bot.answer_callback_query(q.id.clone()).await?; + return Ok(()); + } + + // Structure to hold config with inbound_id + #[derive(Debug, Clone)] + struct ConfigWithInbound { + client_config: crate::services::uri_generator::ClientConfig, + server_inbound_id: uuid::Uuid, + } + + // Group configurations by server name + let mut servers: std::collections::HashMap> = std::collections::HashMap::new(); + + for inbound_user in inbound_users { + if !inbound_user.is_active { + continue; + } + + // Get client config data for this specific inbound + if let Ok(Some(config_data)) = inbound_users_repo.get_client_config_data(user.id, inbound_user.server_inbound_id).await { + match uri_service.generate_client_config(user.id, &config_data) { + Ok(client_config) => { + let config_with_inbound = ConfigWithInbound { + client_config: client_config.clone(), + server_inbound_id: inbound_user.server_inbound_id, + }; + + servers.entry(client_config.server_name.clone()) + .or_insert_with(Vec::new) + .push(config_with_inbound); + }, + Err(e) => { + tracing::warn!("Failed to generate client config: {}", e); + continue; + } + } + } + } + + // Build message with statistics only + let mut message_lines = vec![l10n.get(lang.clone(), "your_configurations")]; + + // Calculate statistics + let server_count = servers.len(); + let total_configs = servers.values().map(|configs| configs.len()).sum::(); + + // Count unique protocols + let mut protocols = std::collections::HashSet::new(); + for configs in servers.values() { + for config_with_inbound in configs { + protocols.insert(config_with_inbound.client_config.protocol.clone()); + } + } + + let server_word = match lang { + Language::Russian => { + if server_count == 1 { "сервер" } + else if server_count < 5 { "сервера" } + else { "серверов" } + }, + Language::English => { + if server_count == 1 { "server" } + else { "servers" } + } + }; + + let config_word = match lang { + Language::Russian => { + if total_configs == 1 { "конфигурация" } + else if total_configs < 5 { "конфигурации" } + else { "конфигураций" } + }, + Language::English => { + if total_configs == 1 { "configuration" } + else { "configurations" } + } + }; + + let protocol_word = match lang { + Language::Russian => { + if protocols.len() == 1 { "протокол" } + else if protocols.len() < 5 { "протокола" } + else { "протоколов" } + }, + Language::English => { + if protocols.len() == 1 { "protocol" } + else { "protocols" } + } + }; + + message_lines.push(format!( + "\n📊 {} {} • {} {} • {} {}", + server_count, server_word, + total_configs, config_word, + protocols.len(), protocol_word + )); + + // Create keyboard with buttons for each server + let mut keyboard_buttons = vec![]; + + for (server_name, configs) in servers.iter() { + // Encode server name to avoid issues with special characters + let encoded_server_name = general_purpose::STANDARD.encode(server_name.as_bytes()); + let config_count = configs.len(); + + let config_suffix = match lang { + Language::Russian => { + if config_count == 1 { + "" + } else if config_count < 5 { + "а" + } else { + "ов" + } + }, + Language::English => { + if config_count == 1 { + "" + } else { + "s" + } + } + }; + + let config_word = match lang { + Language::Russian => "конфиг", + Language::English => "config", + }; + + keyboard_buttons.push(vec![ + InlineKeyboardButton::callback( + format!("🖥️ {} ({} {}{})", server_name, config_count, config_word, config_suffix), + format!("server_configs:{}", encoded_server_name) + ) + ]); + } + + keyboard_buttons.push(vec![ + InlineKeyboardButton::callback(l10n.get(lang, "back"), "back") + ]); + + let message = message_lines.join("\n"); + + // Edit the existing message instead of sending a new one + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(keyboard_buttons)) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + } + + Ok(()) +} + +/// Handle show server configs callback +pub async fn handle_show_server_configs( + bot: Bot, + q: &CallbackQuery, + encoded_server_name: &str, + db: &DatabaseManager, +) -> Result<(), Box> { + let from = &q.from; + let lang = get_user_language(from); + let l10n = LocalizationService::new(); + let telegram_id = from.id.0 as i64; + let chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + // Decode server name + let server_name = match general_purpose::STANDARD.decode(encoded_server_name) { + Ok(bytes) => String::from_utf8(bytes).map_err(|_| "Invalid server name encoding")?, + Err(_) => return Ok(()), // Invalid encoding, ignore + }; + + let user_repo = UserRepository::new(db.connection()); + let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone()); + let uri_service = crate::services::UriGeneratorService::new(); + + // Get user from telegram_id + if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { + // Get all active inbound users for this user + let inbound_users = inbound_users_repo.find_by_user_id(user.id).await.unwrap_or_default(); + + let mut server_configs = Vec::new(); + + for inbound_user in inbound_users { + if !inbound_user.is_active { + continue; + } + + // Get client config data for this specific inbound + if let Ok(Some(config_data)) = inbound_users_repo.get_client_config_data(user.id, inbound_user.server_inbound_id).await { + if config_data.server_name == server_name { + match uri_service.generate_client_config(user.id, &config_data) { + Ok(client_config) => { + server_configs.push(client_config); + }, + Err(e) => { + tracing::warn!("Failed to generate client config: {}", e); + continue; + } + } + } + } + } + + if server_configs.is_empty() { + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "config_not_found")) + .await?; + return Ok(()); + } + + // Build message with all configs for this server + let mut message_lines = vec![ + l10n.format(lang.clone(), "server_configs_title", &[("server_name", &server_name)]) + ]; + + for config in &server_configs { + let protocol_emoji = match config.protocol.as_str() { + "vless" => "🔵", + "vmess" => "🟢", + "trojan" => "🔴", + "shadowsocks" => "🟡", + _ => "⚪" + }; + + message_lines.push(format!( + "\n{} {} - {} ({})", + protocol_emoji, + config.server_name, + config.template_name, + config.protocol.to_uppercase() + )); + + message_lines.push(format!("{}", config.uri)); + } + + // Create back button + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back_to_configs")], + ]); + + let message = message_lines.join("\n"); + + // Edit the existing message instead of sending a new one + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + } else { + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "unauthorized")) + .await?; + } + + Ok(()) +} + +/// Handle support button +pub async fn handle_support( + bot: Bot, + q: &CallbackQuery, +) -> Result<(), Box> { + let from = &q.from; + let lang = get_user_language(from); + let l10n = LocalizationService::new(); + let chat_id = q.message.as_ref().and_then(|m| { + match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + } + }).ok_or("No chat ID")?; + + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back")], + ]); + + // Edit the existing message instead of sending a new one + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang, "support_info")) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + + Ok(()) +} + +/// Notify admins about new access request +async fn notify_admins_new_request( + bot: &Bot, + request: &crate::database::entities::user_request::Model, + db: &DatabaseManager, +) -> Result<(), Box> { + let user_repo = UserRepository::new(db.connection()); + + // Get all admins + let admins = user_repo.get_telegram_admins().await.unwrap_or_default(); + + if !admins.is_empty() { + let lang = Language::English; // Default admin language + let l10n = LocalizationService::new(); + + let message = l10n.format(lang.clone(), "new_access_request", &[ + ("first_name", &request.telegram_first_name.as_deref().unwrap_or("")), + ("last_name", &request.telegram_last_name.as_deref().unwrap_or("")), + ("username", &request.telegram_username.as_deref().unwrap_or("unknown")), + ]); + + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback(l10n.get(lang.clone(), "approve"), format!("approve:{}", request.id)), + InlineKeyboardButton::callback(l10n.get(lang.clone(), "decline"), format!("decline:{}", request.id)), + ], + vec![ + InlineKeyboardButton::callback("📋 All Requests", "back_to_requests"), + ], + ]); + + for admin in admins { + if let Some(telegram_id) = admin.telegram_id { + let _ = bot.send_message(ChatId(telegram_id), &message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard.clone()) + .await; + } + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/services/telegram/localization/mod.rs b/src/services/telegram/localization/mod.rs new file mode 100644 index 0000000..76b0da5 --- /dev/null +++ b/src/services/telegram/localization/mod.rs @@ -0,0 +1,297 @@ +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Language { + Russian, + English, +} + +impl Language { + pub fn from_telegram_code(code: Option<&str>) -> Self { + match code { + Some("ru") | Some("by") | Some("kk") | Some("uk") => Self::Russian, + _ => Self::English, // Default to English + } + } + + pub fn code(&self) -> &'static str { + match self { + Self::Russian => "ru", + Self::English => "en", + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Translations { + pub welcome_new_user: String, + pub welcome_back: String, + pub request_pending: String, + pub request_approved_status: String, + pub request_declined_status: String, + pub get_vpn_access: String, + pub my_configs: String, + pub support: String, + pub user_requests: String, + pub back: String, + pub approve: String, + pub decline: String, + + // Request handling + pub already_pending: String, + pub already_approved: String, + pub already_declined: String, + pub request_submitted: String, + pub request_submit_failed: String, + + // Approval/Decline messages + pub request_approved: String, + pub request_declined: String, + pub request_approved_notification: String, + pub request_declined_notification: String, + + // Admin messages + pub new_access_request: String, + pub no_pending_requests: String, + pub access_request_details: String, + pub unauthorized: String, + pub request_approved_admin: String, + pub request_declined_admin: String, + pub user_creation_failed: String, + + // Support + pub support_info: String, + + // Stats + pub statistics: String, + pub total_users: String, + pub total_servers: String, + pub total_inbounds: String, + pub pending_requests: String, + + // Broadcast + pub broadcast_complete: String, + pub sent: String, + pub failed: String, + + // Configs + pub configs_coming_soon: String, + pub your_configurations: String, + pub no_configs_available: String, + pub config_copy_message: String, + pub config_copied: String, + pub config_not_found: String, + pub server_configs_title: String, + + // Errors + pub error_occurred: String, + pub admin_not_found: String, + pub request_not_found: String, + pub invalid_request_id: String, +} + +pub struct LocalizationService { + translations: HashMap, +} + +impl LocalizationService { + pub fn new() -> Self { + let mut translations = HashMap::new(); + + // Load English translations + translations.insert(Language::English, Self::load_english()); + + // Load Russian translations + translations.insert(Language::Russian, Self::load_russian()); + + Self { translations } + } + + pub fn get(&self, lang: Language, key: &str) -> String { + let translations = self.translations.get(&lang) + .unwrap_or_else(|| self.translations.get(&Language::English).unwrap()); + + match key { + "welcome_new_user" => translations.welcome_new_user.clone(), + "welcome_back" => translations.welcome_back.clone(), + "request_pending" => translations.request_pending.clone(), + "request_approved_status" => translations.request_approved_status.clone(), + "request_declined_status" => translations.request_declined_status.clone(), + "get_vpn_access" => translations.get_vpn_access.clone(), + "my_configs" => translations.my_configs.clone(), + "support" => translations.support.clone(), + "user_requests" => translations.user_requests.clone(), + "back" => translations.back.clone(), + "approve" => translations.approve.clone(), + "decline" => translations.decline.clone(), + "already_pending" => translations.already_pending.clone(), + "already_approved" => translations.already_approved.clone(), + "already_declined" => translations.already_declined.clone(), + "request_submitted" => translations.request_submitted.clone(), + "request_submit_failed" => translations.request_submit_failed.clone(), + "request_approved" => translations.request_approved.clone(), + "request_declined" => translations.request_declined.clone(), + "request_approved_notification" => translations.request_approved_notification.clone(), + "request_declined_notification" => translations.request_declined_notification.clone(), + "new_access_request" => translations.new_access_request.clone(), + "no_pending_requests" => translations.no_pending_requests.clone(), + "access_request_details" => translations.access_request_details.clone(), + "unauthorized" => translations.unauthorized.clone(), + "request_approved_admin" => translations.request_approved_admin.clone(), + "request_declined_admin" => translations.request_declined_admin.clone(), + "user_creation_failed" => translations.user_creation_failed.clone(), + "support_info" => translations.support_info.clone(), + "statistics" => translations.statistics.clone(), + "total_users" => translations.total_users.clone(), + "total_servers" => translations.total_servers.clone(), + "total_inbounds" => translations.total_inbounds.clone(), + "pending_requests" => translations.pending_requests.clone(), + "broadcast_complete" => translations.broadcast_complete.clone(), + "sent" => translations.sent.clone(), + "failed" => translations.failed.clone(), + "configs_coming_soon" => translations.configs_coming_soon.clone(), + "your_configurations" => translations.your_configurations.clone(), + "no_configs_available" => translations.no_configs_available.clone(), + "config_copy_message" => translations.config_copy_message.clone(), + "config_copied" => translations.config_copied.clone(), + "config_not_found" => translations.config_not_found.clone(), + "server_configs_title" => translations.server_configs_title.clone(), + "error_occurred" => translations.error_occurred.clone(), + "admin_not_found" => translations.admin_not_found.clone(), + "request_not_found" => translations.request_not_found.clone(), + "invalid_request_id" => translations.invalid_request_id.clone(), + _ => format!("Missing translation: {}", key), + } + } + + pub fn format(&self, lang: Language, template: &str, args: &[(&str, &str)]) -> String { + let mut result = self.get(lang, template); + for (placeholder, value) in args { + result = result.replace(&format!("{{{}}}", placeholder), value); + } + result + } + + fn load_english() -> Translations { + Translations { + welcome_new_user: "👋 Welcome, {username}!\n\nI'm the OutFleet VPN bot. To get started, you'll need to request access.\n\nClick the button below to submit your access request:".to_string(), + welcome_back: "👋 Welcome back, {name}!\n\nWhat would you like to do?".to_string(), + request_pending: "👋 Hello!\n\nYour access request is currently {status}.\n\nRequest submitted: {date}".to_string(), + request_approved_status: "✅ approved".to_string(), + request_declined_status: "❌ declined".to_string(), + get_vpn_access: "🚀 Get VPN Access".to_string(), + my_configs: "📋 My Configs".to_string(), + support: "💬 Support".to_string(), + user_requests: "👥 User Requests".to_string(), + back: "🔙 Back".to_string(), + approve: "✅ Approve".to_string(), + decline: "❌ Decline".to_string(), + + already_pending: "⏳ You already have a pending access request. Please wait for admin review.".to_string(), + already_approved: "✅ Your access request has already been approved. Use /start to access the main menu.".to_string(), + already_declined: "❌ Your previous access request was declined. Please contact administrators if you believe this is a mistake.".to_string(), + request_submitted: "✅ Your access request has been submitted!\n\nAn administrator will review your request soon. You'll receive a notification once it's processed.".to_string(), + request_submit_failed: "❌ Failed to submit request: {error}".to_string(), + + request_approved: "✅ Request approved".to_string(), + request_declined: "❌ Request declined".to_string(), + request_approved_notification: "🎉 Your access request has been approved!\n\nWelcome to OutFleet VPN! Your account has been created.\n\nUser ID: {user_id}\n\nYou can now use /start to access the main menu.".to_string(), + request_declined_notification: "❌ Your access request has been declined.\n\nIf you believe this is a mistake, please contact the administrators.".to_string(), + + new_access_request: "🔔 New Access Request\n\n👤 Name: {first_name} {last_name}\n🆔 Username: @{username}\n\nUse /requests to review".to_string(), + no_pending_requests: "No pending access requests".to_string(), + access_request_details: "📋 Access Request\n\n👤 Name: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Requested: {date}\n\nMessage: {message}".to_string(), + unauthorized: "❌ You are not authorized to use this command".to_string(), + request_approved_admin: "✅ Request approved".to_string(), + request_declined_admin: "❌ Request declined".to_string(), + user_creation_failed: "❌ Failed to create user account: {error}\n\nPlease try again or contact technical support.".to_string(), + + support_info: "💬 Support Information\n\nIf you need help, please contact the administrators.\n\nYou can also check the documentation at:\nhttps://github.com/OutFleet".to_string(), + + statistics: "📊 Statistics\n\n👥 Total Users: {users}\n🖥️ Total Servers: {servers}\n📡 Total Inbounds: {inbounds}\n⏳ Pending Requests: {pending}".to_string(), + total_users: "👥 Total Users".to_string(), + total_servers: "🖥️ Total Servers".to_string(), + total_inbounds: "📡 Total Inbounds".to_string(), + pending_requests: "⏳ Pending Requests".to_string(), + + broadcast_complete: "✅ Broadcast complete\nSent: {sent}\nFailed: {failed}".to_string(), + sent: "Sent".to_string(), + failed: "Failed".to_string(), + + configs_coming_soon: "📋 Your configurations will be shown here (coming soon)".to_string(), + your_configurations: "📋 Your Configurations".to_string(), + no_configs_available: "📋 No configurations available\n\nYou don't have access to any VPN configurations yet. Please contact an administrator to get access.".to_string(), + config_copy_message: "📋 {server_name} - {inbound_tag} ({protocol})\n\nConnection URI:".to_string(), + config_copied: "✅ Configuration copied to clipboard".to_string(), + config_not_found: "❌ Configuration not found".to_string(), + server_configs_title: "🖥️ {server_name} - Connection Links".to_string(), + + error_occurred: "An error occurred".to_string(), + admin_not_found: "Admin not found".to_string(), + request_not_found: "Request not found".to_string(), + invalid_request_id: "Invalid request ID".to_string(), + } + } + + fn load_russian() -> Translations { + Translations { + welcome_new_user: "👋 Добро пожаловать, {username}!\n\nЯ бот OutFleet VPN. Чтобы начать работу, вам необходимо запросить доступ.\n\nНажмите кнопку ниже, чтобы отправить запрос на доступ:".to_string(), + welcome_back: "👋 С возвращением, {name}!\n\nЧто вы хотите сделать?".to_string(), + request_pending: "👋 Привет!\n\nВаш запрос на доступ в настоящее время {status}.\n\nЗапрос отправлен: {date}".to_string(), + request_approved_status: "✅ одобрен".to_string(), + request_declined_status: "❌ отклонен".to_string(), + get_vpn_access: "🚀 Получить доступ к VPN".to_string(), + my_configs: "📋 Мои конфигурации".to_string(), + support: "💬 Поддержка".to_string(), + user_requests: "👥 Запросы пользователей".to_string(), + back: "🔙 Назад".to_string(), + approve: "✅ Одобрить".to_string(), + decline: "❌ Отклонить".to_string(), + + already_pending: "⏳ У вас уже есть ожидающий рассмотрения запрос на доступ. Пожалуйста, дождитесь проверки администратором.".to_string(), + already_approved: "✅ Ваш запрос на доступ уже был одобрен. Используйте /start для доступа к главному меню.".to_string(), + already_declined: "❌ Ваш предыдущий запрос на доступ был отклонен. Пожалуйста, свяжитесь с администраторами, если считаете, что это ошибка.".to_string(), + request_submitted: "✅ Ваш запрос на доступ отправлен!\n\nАдминистратор скоро рассмотрит ваш запрос. Вы получите уведомление после обработки.".to_string(), + request_submit_failed: "❌ Не удалось отправить запрос: {error}".to_string(), + + request_approved: "✅ Запрос одобрен".to_string(), + request_declined: "❌ Запрос отклонен".to_string(), + request_approved_notification: "🎉 Ваш запрос на доступ одобрен!\n\nДобро пожаловать в OutFleet VPN! Ваш аккаунт создан.\n\nID пользователя: {user_id}\n\nТеперь вы можете использовать /start для доступа к главному меню.".to_string(), + request_declined_notification: "❌ Ваш запрос на доступ отклонен.\n\nЕсли вы считаете, что это ошибка, пожалуйста, свяжитесь с администраторами.".to_string(), + + new_access_request: "🔔 Новый запрос на доступ\n\n👤 Имя: {first_name} {last_name}\n🆔 Имя пользователя: @{username}\n\nИспользуйте /requests для просмотра".to_string(), + no_pending_requests: "Нет ожидающих запросов на доступ".to_string(), + access_request_details: "📋 Запрос на доступ\n\n👤 Имя: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Запрошено: {date}\n\nСообщение: {message}".to_string(), + unauthorized: "❌ У вас нет прав для использования этой команды".to_string(), + request_approved_admin: "✅ Запрос одобрен".to_string(), + request_declined_admin: "❌ Запрос отклонен".to_string(), + user_creation_failed: "❌ Не удалось создать аккаунт пользователя: {error}\n\nПожалуйста, попробуйте еще раз или обратитесь в техническую поддержку.".to_string(), + + support_info: "💬 Информация о поддержке\n\nЕсли вам нужна помощь, пожалуйста, свяжитесь с администраторами.\n\nВы также можете ознакомиться с документацией по адресу:\nhttps://github.com/OutFleet".to_string(), + + statistics: "📊 Статистика\n\n👥 Всего пользователей: {users}\n🖥️ Всего серверов: {servers}\n📡 Всего входящих подключений: {inbounds}\n⏳ Ожидающих запросов: {pending}".to_string(), + total_users: "👥 Всего пользователей".to_string(), + total_servers: "🖥️ Всего серверов".to_string(), + total_inbounds: "📡 Всего входящих подключений".to_string(), + pending_requests: "⏳ Ожидающих запросов".to_string(), + + broadcast_complete: "✅ Рассылка завершена\nОтправлено: {sent}\nНе удалось: {failed}".to_string(), + sent: "Отправлено".to_string(), + failed: "Не удалось".to_string(), + + configs_coming_soon: "📋 Ваши конфигурации будут показаны здесь (скоро)".to_string(), + your_configurations: "📋 Ваши конфигурации".to_string(), + no_configs_available: "📋 Нет доступных конфигураций\n\nУ вас пока нет доступа к конфигурациям VPN. Пожалуйста, обратитесь к администратору для получения доступа.".to_string(), + config_copy_message: "📋 {server_name} - {inbound_tag} ({protocol})\n\nСсылка для подключения:".to_string(), + config_copied: "✅ Конфигурация скопирована в буфер обмена".to_string(), + config_not_found: "❌ Конфигурация не найдена".to_string(), + server_configs_title: "🖥️ {server_name} - Ссылки для подключения".to_string(), + + error_occurred: "Произошла ошибка".to_string(), + admin_not_found: "Администратор не найден".to_string(), + request_not_found: "Запрос не найден".to_string(), + invalid_request_id: "Неверный ID запроса".to_string(), + } + } +} \ No newline at end of file diff --git a/src/services/telegram/mod.rs b/src/services/telegram/mod.rs index c1558c0..54caf0f 100644 --- a/src/services/telegram/mod.rs +++ b/src/services/telegram/mod.rs @@ -11,6 +11,7 @@ use crate::database::entities::telegram_config::Model as TelegramConfig; pub mod bot; pub mod handlers; pub mod error; +pub mod localization; pub use error::TelegramError; diff --git a/src/services/uri_generator/builders/mod.rs b/src/services/uri_generator/builders/mod.rs index ecd2a78..fe94c28 100644 --- a/src/services/uri_generator/builders/mod.rs +++ b/src/services/uri_generator/builders/mod.rs @@ -119,7 +119,7 @@ pub mod utils { } /// Determine alias for the URI - pub fn generate_alias(user_name: &str, server_name: &str, inbound_tag: &str) -> String { - format!("{}@{}-{}", user_name, server_name, inbound_tag) + pub fn generate_alias(server_name: &str, template_name: &str) -> String { + format!("{} - {}", server_name, template_name) } } \ No newline at end of file diff --git a/src/services/uri_generator/builders/shadowsocks.rs b/src/services/uri_generator/builders/shadowsocks.rs index eb86016..98ac154 100644 --- a/src/services/uri_generator/builders/shadowsocks.rs +++ b/src/services/uri_generator/builders/shadowsocks.rs @@ -56,7 +56,7 @@ impl UriBuilder for ShadowsocksUriBuilder { let encoded_credentials = general_purpose::STANDARD.encode(credentials.as_bytes()); // Generate alias for the URI - let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag); + let alias = utils::generate_alias(&config.server_name, &config.template_name); // Build simple SIP002 URI (no plugin parameters for standard Shadowsocks) let uri = format!( diff --git a/src/services/uri_generator/builders/trojan.rs b/src/services/uri_generator/builders/trojan.rs index 96877c2..e18c34a 100644 --- a/src/services/uri_generator/builders/trojan.rs +++ b/src/services/uri_generator/builders/trojan.rs @@ -139,7 +139,7 @@ impl UriBuilder for TrojanUriBuilder { // Build the URI let query_string = utils::build_query_string(¶ms); - let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag); + let alias = utils::generate_alias(&config.server_name, &config.template_name); let uri = if query_string.is_empty() { format!( diff --git a/src/services/uri_generator/builders/vless.rs b/src/services/uri_generator/builders/vless.rs index dbc9672..f0fc199 100644 --- a/src/services/uri_generator/builders/vless.rs +++ b/src/services/uri_generator/builders/vless.rs @@ -113,7 +113,7 @@ impl UriBuilder for VlessUriBuilder { // Build the URI let query_string = utils::build_query_string(¶ms); - let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag); + let alias = utils::generate_alias(&config.server_name, &config.template_name); let uri = if query_string.is_empty() { format!( diff --git a/src/services/uri_generator/builders/vmess.rs b/src/services/uri_generator/builders/vmess.rs index 5d9169c..e55f98b 100644 --- a/src/services/uri_generator/builders/vmess.rs +++ b/src/services/uri_generator/builders/vmess.rs @@ -34,7 +34,7 @@ impl VmessUriBuilder { "net": transport_type, "path": "", "port": config.port, - "ps": utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag), + "ps": utils::generate_alias(&config.server_name, &config.template_name), "scy": "auto", "tls": if security == "none" { "none" } else { &security }, "type": "none", @@ -196,7 +196,7 @@ impl VmessUriBuilder { // Build the URI let query_string = utils::build_query_string(¶ms); - let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag); + let alias = utils::generate_alias(&config.server_name, &config.template_name); let uri = if query_string.is_empty() { format!( diff --git a/src/services/uri_generator/mod.rs b/src/services/uri_generator/mod.rs index 8b0e02d..7b16775 100644 --- a/src/services/uri_generator/mod.rs +++ b/src/services/uri_generator/mod.rs @@ -37,6 +37,7 @@ pub struct ClientConfigData { // Metadata pub server_name: String, pub inbound_tag: String, + pub template_name: String, } /// Generated client configuration @@ -45,6 +46,7 @@ pub struct ClientConfig { pub user_id: Uuid, pub server_name: String, pub inbound_tag: String, + pub template_name: String, pub protocol: String, pub uri: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -92,6 +94,7 @@ impl UriGeneratorService { user_id, server_name: config.server_name.clone(), inbound_tag: config.inbound_tag.clone(), + template_name: config.template_name.clone(), protocol: config.protocol.clone(), uri, qr_code: None, // TODO: Implement QR code generation if needed diff --git a/src/services/xray/client.rs b/src/services/xray/client.rs index d860c10..091ea24 100644 --- a/src/services/xray/client.rs +++ b/src/services/xray/client.rs @@ -2,6 +2,7 @@ use anyhow::{Result, anyhow}; use serde_json::Value; use xray_core::Client; use std::sync::Arc; +use tokio::time::{timeout, Duration}; // Import submodules from the same directory use super::stats::StatsClient; @@ -17,17 +18,25 @@ pub struct XrayClient { #[allow(dead_code)] impl XrayClient { - /// Connect to Xray gRPC server + /// Connect to Xray gRPC server with timeout pub async fn connect(endpoint: &str) -> Result { - let client = Client::from_url(endpoint).await - .map_err(|e| anyhow!("Failed to connect to Xray at {}: {}", endpoint, e))?; - - // Don't clone - we'll use &self.client when calling methods - - Ok(Self { - endpoint: endpoint.to_string(), - client: Arc::new(client), - }) + // Apply a 5-second timeout to the connection attempt + let connect_future = Client::from_url(endpoint); + + match timeout(Duration::from_secs(5), connect_future).await { + Ok(Ok(client)) => { + Ok(Self { + endpoint: endpoint.to_string(), + client: Arc::new(client), + }) + }, + Ok(Err(e)) => { + Err(anyhow!("Failed to connect to Xray at {}: {}", endpoint, e)) + }, + Err(_) => { + Err(anyhow!("Connection to Xray at {} timed out after 5 seconds", endpoint)) + } + } } /// Get server statistics diff --git a/src/services/xray/mod.rs b/src/services/xray/mod.rs index 2773d8a..8dc5b17 100644 --- a/src/services/xray/mod.rs +++ b/src/services/xray/mod.rs @@ -4,8 +4,8 @@ use uuid::Uuid; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; -use tokio::time::{Duration, Instant}; -use tracing::error; +use tokio::time::{Duration, Instant, timeout}; +use tracing::{error, warn}; pub mod client; pub mod config; @@ -78,15 +78,24 @@ impl XrayService { } - /// Test connection to Xray server + /// Test connection to Xray server with timeout pub async fn test_connection(&self, _server_id: Uuid, endpoint: &str) -> Result { - match self.get_or_create_client(endpoint).await { - Ok(_client) => { - // Instead of getting stats (which might fail), just test connection - // If we successfully created the client, connection is working + // Apply a 3-second timeout to the entire test operation + match timeout(Duration::from_secs(3), self.get_or_create_client(endpoint)).await { + Ok(Ok(_client)) => { + // Connection successful Ok(true) }, - Err(_) => Ok(false), + Ok(Err(e)) => { + // Connection failed with error + warn!("Failed to connect to Xray at {}: {}", endpoint, e); + Ok(false) + }, + Err(_) => { + // Operation timed out + warn!("Connection test to Xray at {} timed out", endpoint); + Ok(false) + } } } diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 088e6a0..beb0ce9 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod client_configs; pub mod dns_providers; pub mod tasks; pub mod telegram; +pub mod user_requests; pub use users::*; pub use servers::*; @@ -14,4 +15,5 @@ pub use templates::*; pub use client_configs::*; pub use dns_providers::*; pub use tasks::*; -pub use telegram::*; \ No newline at end of file +pub use telegram::*; +pub use user_requests::*; \ No newline at end of file diff --git a/src/web/handlers/user_requests.rs b/src/web/handlers/user_requests.rs new file mode 100644 index 0000000..ff2fa81 --- /dev/null +++ b/src/web/handlers/user_requests.rs @@ -0,0 +1,224 @@ +use axum::{ + extract::{Path, Query, State}, + Json, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + database::entities::user_request::{CreateUserRequestDto, UpdateUserRequestDto, RequestStatus}, + database::repository::UserRequestRepository, + web::AppState, +}; + +#[derive(Debug, Deserialize)] +pub struct RequestsQuery { + #[serde(default = "default_page")] + page: u64, + #[serde(default = "default_per_page")] + per_page: u64, + #[serde(default)] + status: Option, +} + +fn default_page() -> u64 { 1 } +fn default_per_page() -> u64 { 20 } + +#[derive(Debug, Serialize)] +pub struct RequestsResponse { + items: Vec, + total: u64, + page: u64, + per_page: u64, +} + +#[derive(Debug, Serialize)] +pub struct UserRequestResponse { + id: Uuid, + user_id: Option, + telegram_id: i64, + telegram_username: Option, + telegram_first_name: Option, + telegram_last_name: Option, + full_name: String, + telegram_link: String, + status: String, + request_message: Option, + response_message: Option, + processed_by_user_id: Option, + processed_at: Option>, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +impl From for UserRequestResponse { + fn from(model: crate::database::entities::user_request::Model) -> Self { + Self { + id: model.id, + user_id: model.user_id, + telegram_id: model.telegram_id, + telegram_username: model.telegram_username.clone(), + telegram_first_name: model.telegram_first_name.clone(), + telegram_last_name: model.telegram_last_name.clone(), + full_name: model.get_full_name(), + telegram_link: model.get_telegram_link(), + status: model.status, + request_message: model.request_message, + response_message: model.response_message, + processed_by_user_id: model.processed_by_user_id, + processed_at: model.processed_at.map(|dt| dt.into()), + created_at: model.created_at.into(), + updated_at: model.updated_at.into(), + } + } +} + +/// Get user requests with pagination +pub async fn get_requests( + State(state): State, + Query(query): Query, +) -> Result, StatusCode> { + let request_repo = UserRequestRepository::new(state.db.connection()); + + let (items, total) = if let Some(status) = query.status { + // Filter by status + match status.as_str() { + "pending" => request_repo.find_pending(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + _ => request_repo.find_all(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + } + } else { + request_repo.find_all(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + }; + + let items: Vec = items.into_iter().map(Into::into).collect(); + + Ok(Json(RequestsResponse { + items, + total, + page: query.page, + per_page: query.per_page, + })) +} + +/// Get a single user request +pub async fn get_request( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let request_repo = UserRequestRepository::new(state.db.connection()); + + match request_repo.find_by_id(id).await { + Ok(Some(request)) => Ok(Json(UserRequestResponse::from(request))), + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +#[derive(Debug, Deserialize)] +pub struct ApproveRequestDto { + response_message: Option, +} + +/// Approve a user request +pub async fn approve_request( + State(state): State, + Path(id): Path, + Json(dto): Json, +) -> Result, StatusCode> { + let request_repo = UserRequestRepository::new(state.db.connection()); + let user_repo = crate::database::repository::UserRepository::new(state.db.connection()); + + // Get the request + let request = match request_repo.find_by_id(id).await { + Ok(Some(request)) => request, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + + // Check if already processed + if request.status != "pending" { + return Err(StatusCode::BAD_REQUEST); + } + + // Create user account + let username = request.telegram_username.as_deref().unwrap_or("Unknown"); + let user_dto = crate::database::entities::user::CreateUserDto { + name: request.get_full_name(), + comment: Some(format!("Telegram user: @{}", username)), + telegram_id: Some(request.telegram_id), + is_telegram_admin: false, + }; + + match user_repo.create(user_dto).await { + Ok(new_user) => { + // Approve the request + let approved = match request_repo.approve(id, dto.response_message, new_user.id).await { + Ok(Some(approved)) => approved, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + + // TODO: Send Telegram notification to user + + Ok(Json(UserRequestResponse::from(approved))) + } + Err(_) => { + Err(StatusCode::BAD_REQUEST) + } + } +} + +#[derive(Debug, Deserialize)] +pub struct DeclineRequestDto { + response_message: Option, +} + +/// Decline a user request +pub async fn decline_request( + State(state): State, + Path(id): Path, + Json(dto): Json, +) -> Result, StatusCode> { + let request_repo = UserRequestRepository::new(state.db.connection()); + + // Get the request + let request = match request_repo.find_by_id(id).await { + Ok(Some(request)) => request, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + + // Check if already processed + if request.status != "pending" { + return Err(StatusCode::BAD_REQUEST); + } + + // Use a default user ID for declined requests (we can set it to the first admin user) + let dummy_user_id = Uuid::new_v4(); + + // Decline the request + let declined = match request_repo.decline(id, dto.response_message, dummy_user_id).await { + Ok(Some(declined)) => declined, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + + // TODO: Send Telegram notification to user + + Ok(Json(UserRequestResponse::from(declined))) +} + +/// Delete a user request +pub async fn delete_request( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let request_repo = UserRequestRepository::new(state.db.connection()); + + match request_repo.delete(id).await { + Ok(true) => Ok(Json(serde_json::json!({ "message": "User request deleted" }))), + Ok(false) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} \ No newline at end of file diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs index 5abcfe3..a5f8a50 100644 --- a/src/web/routes/mod.rs +++ b/src/web/routes/mod.rs @@ -1,6 +1,6 @@ use axum::{ Router, - routing::{get, post}, + routing::{get, post, put, delete}, }; use crate::web::{AppState, handlers}; @@ -17,6 +17,7 @@ pub fn api_routes() -> Router { .nest("/dns-providers", dns_provider_routes()) .nest("/tasks", task_routes()) .nest("/telegram", telegram_routes()) + .nest("/user-requests", user_request_routes()) } /// User management routes @@ -64,4 +65,13 @@ fn telegram_routes() -> Router { post(handlers::add_telegram_admin) .delete(handlers::remove_telegram_admin)) .route("/send", post(handlers::send_test_message)) +} + +/// User request management routes +fn user_request_routes() -> Router { + Router::new() + .route("/", get(handlers::get_requests)) + .route("/:id", get(handlers::get_request).delete(handlers::delete_request)) + .route("/:id/approve", post(handlers::approve_request)) + .route("/:id/decline", post(handlers::decline_request)) } \ No newline at end of file diff --git a/static/admin.html b/static/admin.html index d31c8f4..87411ca 100644 --- a/static/admin.html +++ b/static/admin.html @@ -906,6 +906,103 @@ color: #1d1d1f; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; } + + /* User Requests Styles */ + .request-cards { + display: flex; + flex-direction: column; + gap: 16px; + } + + .request-card { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 20px; + background: #fafafa; + } + + .request-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .request-header h4 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + + .request-info { + margin-bottom: 16px; + } + + .request-info p { + margin: 8px 0; + color: #666; + } + + .request-actions { + display: flex; + gap: 12px; + } + + .badge { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .badge-warning { + background: #ffc107; + color: #000; + } + + .badge-success { + background: #28a745; + color: white; + } + + .badge-danger { + background: #dc3545; + color: white; + } + + .badge-secondary { + background: #6c757d; + color: white; + } + + .button-success { + background: #28a745; + color: white; + border: none; + } + + .button-success:hover { + background: #218838; + } + + .button-danger { + background: #dc3545; + color: white; + border: none; + } + + .button-danger:hover { + background: #c82333; + } + + .button-small { + padding: 6px 12px; + font-size: 14px; + } @@ -940,6 +1037,9 @@ + @@ -1293,6 +1393,57 @@ + + +
+ + + +
+
+

Request Overview

+
+
+
+
+
0
+
Pending
+
+
+
0
+
Approved
+
+
+
0
+
Declined
+
+
+
+
+ + +
+
+

Pending Requests

+ +
+
Loading...
+
+ + +
+
+

All Requests

+
+
Loading...
+
+
@@ -2966,11 +3117,246 @@ } }); + // User Requests Functions + async function loadUserRequests() { + await loadRequestStats(); + await loadPendingRequests(); + await loadAllRequests(); + } + + async function loadRequestStats() { + try { + const response = await fetch(`${API_BASE}/user-requests`); + if (response.ok) { + const data = await response.json(); + const requests = data.items || []; + + const stats = { + pending: requests.filter(r => r.status === 'pending').length, + approved: requests.filter(r => r.status === 'approved').length, + declined: requests.filter(r => r.status === 'declined').length + }; + + document.getElementById('pendingRequests').textContent = stats.pending; + document.getElementById('approvedRequests').textContent = stats.approved; + document.getElementById('declinedRequests').textContent = stats.declined; + } + } catch (error) { + console.error('Error loading request stats:', error); + } + } + + async function loadPendingRequests() { + try { + const response = await fetch(`${API_BASE}/user-requests?status=pending`); + if (response.ok) { + const data = await response.json(); + renderPendingRequests(data.items || []); + } else { + document.getElementById('pendingRequestsTable').innerHTML = '

Failed to load pending requests

'; + } + } catch (error) { + console.error('Error loading pending requests:', error); + document.getElementById('pendingRequestsTable').innerHTML = '

Error loading pending requests

'; + } + } + + async function loadAllRequests() { + try { + const response = await fetch(`${API_BASE}/user-requests`); + if (response.ok) { + const data = await response.json(); + renderAllRequests(data.items || []); + } else { + document.getElementById('allRequestsTable').innerHTML = '

Failed to load requests

'; + } + } catch (error) { + console.error('Error loading requests:', error); + document.getElementById('allRequestsTable').innerHTML = '

Error loading requests

'; + } + } + + function renderPendingRequests(requests) { + const container = document.getElementById('pendingRequestsTable'); + + if (requests.length === 0) { + container.innerHTML = '

No pending requests

'; + return; + } + + const html = ` +
+ ${requests.map(request => ` +
+
+

${escapeHtml(request.full_name)}

+ Pending +
+
+

📱 Telegram: ${request.telegram_username ? '@' + escapeHtml(request.telegram_username) : 'ID: ' + request.telegram_id}

+

📅 Requested: ${new Date(request.created_at).toLocaleString()}

+ ${request.request_message ? `

💬 Message: ${escapeHtml(request.request_message)}

` : ''} +
+
+ + +
+
+ `).join('')} +
+ `; + + container.innerHTML = html; + container.classList.remove('loading'); + } + + function renderAllRequests(requests) { + const container = document.getElementById('allRequestsTable'); + + if (requests.length === 0) { + container.innerHTML = '

No requests found

'; + return; + } + + const html = ` +
+ + + + + + + + + + + + + ${requests.map(request => ` + + + + + + + + + `).join('')} + +
NameTelegramStatusRequestedProcessed ByActions
${escapeHtml(request.full_name)}${request.telegram_username ? '@' + escapeHtml(request.telegram_username) : 'ID: ' + request.telegram_id} + + ${escapeHtml(request.status)} + + ${new Date(request.created_at).toLocaleString()}${request.processed_at ? new Date(request.processed_at).toLocaleString() : '-'} + ${request.status === 'pending' ? ` + + + ` : ` + + `} +
+
+ `; + + container.innerHTML = html; + container.classList.remove('loading'); + } + + function getStatusBadgeClass(status) { + switch (status) { + case 'pending': return 'warning'; + case 'approved': return 'success'; + case 'declined': return 'danger'; + default: return 'secondary'; + } + } + + async function approveRequest(requestId) { + const message = prompt('Optional message for the user:'); + + try { + const response = await fetch(`${API_BASE}/user-requests/${requestId}/approve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ response_message: message }) + }); + + if (response.ok) { + showAlert('Request approved successfully', 'success'); + await loadUserRequests(); + } else { + const error = await response.text(); + showAlert('Failed to approve request: ' + error, 'error'); + } + } catch (error) { + showAlert('Error approving request: ' + error.message, 'error'); + } + } + + async function declineRequest(requestId) { + const message = prompt('Reason for declining (optional):'); + + if (message === null) return; // User cancelled + + try { + const response = await fetch(`${API_BASE}/user-requests/${requestId}/decline`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ response_message: message }) + }); + + if (response.ok) { + showAlert('Request declined', 'info'); + await loadUserRequests(); + } else { + const error = await response.text(); + showAlert('Failed to decline request: ' + error, 'error'); + } + } catch (error) { + showAlert('Error declining request: ' + error.message, 'error'); + } + } + + async function deleteRequest(requestId) { + if (!confirm('Are you sure you want to delete this request?')) return; + + try { + const response = await fetch(`${API_BASE}/user-requests/${requestId}`, { + method: 'DELETE' + }); + + if (response.ok) { + showAlert('Request deleted', 'success'); + await loadUserRequests(); + } else { + showAlert('Failed to delete request', 'error'); + } + } catch (error) { + showAlert('Error deleting request: ' + error.message, 'error'); + } + } + // Update loadPageData function to include telegram const originalLoadPageData = window.loadPageData; window.loadPageData = function(page) { if (page === 'telegram') { loadTelegram(); + } else if (page === 'user-requests') { + loadUserRequests(); } else if (originalLoadPageData) { originalLoadPageData(page); }