feat(auth): Add OTP password display on bootstrap and fix Zitadel login flow
- Add generate_secure_password() for OTP generation during admin bootstrap - Display admin credentials (username/password) in console on first run - Save credentials to ~/.gb-setup-credentials file - Fix Zitadel client to support PAT token authentication - Replace OAuth2 password grant with Zitadel Session API for login - Fix get_current_user to fetch user data from Zitadel session - Return session_id as access_token for proper authentication - Set email as verified on user creation to skip verification - Add password grant type to OAuth application config - Update directory_setup to include proper redirect URIs
This commit is contained in:
parent
29b80f597c
commit
479950945b
42 changed files with 10666 additions and 411 deletions
42
.product
Normal file
42
.product
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Product Configuration File
|
||||
# This file defines white-label settings for the application.
|
||||
#
|
||||
# All occurrences of "General Bots" will be replaced by the 'name' value.
|
||||
# Only apps listed in 'apps' will be active in the suite (and their APIs enabled).
|
||||
# The 'theme' value sets the default theme for the UI.
|
||||
|
||||
# Product name (replaces "General Bots" throughout the application)
|
||||
name=General Bots
|
||||
|
||||
# Active apps (comma-separated list)
|
||||
# Available apps: chat, mail, calendar, drive, tasks, docs, paper, sheet, slides,
|
||||
# meet, research, sources, analytics, admin, monitoring, settings
|
||||
# Only listed apps will be visible in the UI and have their APIs enabled.
|
||||
apps=chat,mail,calendar,drive,tasks,docs,paper,sheet,slides,meet,research,sources,analytics,admin,monitoring,settings
|
||||
|
||||
# Default theme
|
||||
# Available themes: dark, light, blue, purple, green, orange, sentient, cyberpunk,
|
||||
# retrowave, vapordream, y2kglow, arcadeflash, discofever, grungeera,
|
||||
# jazzage, mellowgold, midcenturymod, polaroidmemories, saturdaycartoons,
|
||||
# seasidepostcard, typewriter, 3dbevel, xeroxui, xtreegold
|
||||
theme=sentient
|
||||
|
||||
# Logo URL (optional - leave empty to use default)
|
||||
# Can be a relative path or absolute URL
|
||||
logo=
|
||||
|
||||
# Favicon URL (optional - leave empty to use default)
|
||||
favicon=
|
||||
|
||||
# Primary color override (optional - hex color code)
|
||||
# Example: #d4f505
|
||||
primary_color=
|
||||
|
||||
# Support email (optional)
|
||||
support_email=
|
||||
|
||||
# Documentation URL (optional)
|
||||
docs_url=https://docs.pragmatismo.com.br
|
||||
|
||||
# Copyright text (optional - {year} will be replaced with current year)
|
||||
copyright=© {year} {name}. All rights reserved.
|
||||
|
|
@ -40,11 +40,11 @@ repository = "https://github.com/GeneralBots/BotServer"
|
|||
|
||||
[dependencies.botlib]
|
||||
path = "../botlib"
|
||||
features = ["database"]
|
||||
features = ["database", "i18n"]
|
||||
|
||||
[features]
|
||||
# ===== DEFAULT FEATURE SET =====
|
||||
default = ["console", "chat", "automation", "tasks", "drive", "llm", "cache", "progress-bars", "directory", "calendar", "meet", "email"]
|
||||
default = ["console", "chat", "automation", "tasks", "drive", "llm", "cache", "progress-bars", "directory", "calendar", "meet", "email", "whatsapp", "telegram"]
|
||||
|
||||
# ===== UI FEATURES =====
|
||||
console = ["dep:crossterm", "dep:ratatui", "monitoring"]
|
||||
|
|
@ -57,6 +57,7 @@ nvidia = []
|
|||
# ===== COMMUNICATION CHANNELS =====
|
||||
email = ["dep:imap", "dep:lettre", "dep:mailparse", "dep:native-tls"]
|
||||
whatsapp = []
|
||||
telegram = []
|
||||
instagram = []
|
||||
msteams = []
|
||||
|
||||
|
|
|
|||
20
config/directory_config.json
Normal file
20
config/directory_config.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"base_url": "http://localhost:8300",
|
||||
"default_org": {
|
||||
"id": "354422182425657358",
|
||||
"name": "default",
|
||||
"domain": "default.localhost"
|
||||
},
|
||||
"default_user": {
|
||||
"id": "admin",
|
||||
"username": "admin",
|
||||
"email": "admin@localhost",
|
||||
"password": "",
|
||||
"first_name": "Admin",
|
||||
"last_name": "User"
|
||||
},
|
||||
"admin_token": "DNSctgJla8Kl3rWXa1Pk6vqbeiRGixGLfDhQ80m0fNI5H-5Lh4NJBs68bMwFFleh14Xtsto",
|
||||
"project_id": "354422182828310542",
|
||||
"client_id": "354423066903773198",
|
||||
"client_secret": "hsUDIhIA0aaDD52mpzci12DR1ot8g7x1T1DoTJmVzIQ3Y273eDEWYFXiN6pcTVJf"
|
||||
}
|
||||
|
|
@ -53,10 +53,7 @@ pub fn configure_attendance_routes() -> Router<Arc<AppState>> {
|
|||
ApiUrls::ATTENDANCE_TRANSFER,
|
||||
post(queue::transfer_conversation),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::ATTENDANCE_RESOLVE.replace(":session_id", "{session_id}"),
|
||||
post(queue::resolve_conversation),
|
||||
)
|
||||
.route(ApiUrls::ATTENDANCE_RESOLVE, post(queue::resolve_conversation))
|
||||
.route(ApiUrls::ATTENDANCE_INSIGHTS, get(queue::get_insights))
|
||||
.route(ApiUrls::ATTENDANCE_RESPOND, post(attendant_respond))
|
||||
.route(ApiUrls::WS_ATTENDANT, get(attendant_websocket_handler))
|
||||
|
|
@ -72,18 +69,12 @@ pub fn configure_attendance_routes() -> Router<Arc<AppState>> {
|
|||
ApiUrls::ATTENDANCE_LLM_SMART_REPLIES,
|
||||
post(llm_assist::generate_smart_replies),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::ATTENDANCE_LLM_SUMMARY.replace(":session_id", "{session_id}"),
|
||||
get(llm_assist::generate_summary),
|
||||
)
|
||||
.route(ApiUrls::ATTENDANCE_LLM_SUMMARY, get(llm_assist::generate_summary))
|
||||
.route(
|
||||
ApiUrls::ATTENDANCE_LLM_SENTIMENT,
|
||||
post(llm_assist::analyze_sentiment),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::ATTENDANCE_LLM_CONFIG.replace(":bot_id", "{bot_id}"),
|
||||
get(llm_assist::get_llm_config),
|
||||
)
|
||||
.route(ApiUrls::ATTENDANCE_LLM_CONFIG, get(llm_assist::get_llm_config))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -61,76 +61,31 @@ pub fn configure_autotask_routes() -> axum::Router<std::sync::Arc<crate::shared:
|
|||
.route(ApiUrls::AUTOTASK_CLASSIFY, post(classify_intent_handler))
|
||||
.route(ApiUrls::AUTOTASK_COMPILE, post(compile_intent_handler))
|
||||
.route(ApiUrls::AUTOTASK_EXECUTE, post(execute_plan_handler))
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_SIMULATE.replace(":plan_id", "{plan_id}"),
|
||||
post(simulate_plan_handler),
|
||||
)
|
||||
.route(ApiUrls::AUTOTASK_SIMULATE, post(simulate_plan_handler))
|
||||
.route(ApiUrls::AUTOTASK_LIST, get(list_tasks_handler))
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_GET.replace(":task_id", "{task_id}"),
|
||||
get(get_task_handler),
|
||||
)
|
||||
.route(ApiUrls::AUTOTASK_GET, get(get_task_handler))
|
||||
.route(ApiUrls::AUTOTASK_STATS, get(get_stats_handler))
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_PAUSE.replace(":task_id", "{task_id}"),
|
||||
post(pause_task_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_RESUME.replace(":task_id", "{task_id}"),
|
||||
post(resume_task_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_CANCEL.replace(":task_id", "{task_id}"),
|
||||
post(cancel_task_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_TASK_SIMULATE.replace(":task_id", "{task_id}"),
|
||||
post(simulate_task_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_DECISIONS.replace(":task_id", "{task_id}"),
|
||||
get(get_decisions_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_DECIDE.replace(":task_id", "{task_id}"),
|
||||
post(submit_decision_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_APPROVALS.replace(":task_id", "{task_id}"),
|
||||
get(get_approvals_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_APPROVE.replace(":task_id", "{task_id}"),
|
||||
post(submit_approval_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_TASK_EXECUTE.replace(":task_id", "{task_id}"),
|
||||
post(execute_task_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_LOGS.replace(":task_id", "{task_id}"),
|
||||
get(get_task_logs_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/autotask/{task_id}/manifest",
|
||||
get(get_manifest_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_RECOMMENDATIONS_APPLY.replace(":rec_id", "{rec_id}"),
|
||||
post(apply_recommendation_handler),
|
||||
)
|
||||
.route(ApiUrls::AUTOTASK_PAUSE, post(pause_task_handler))
|
||||
.route(ApiUrls::AUTOTASK_RESUME, post(resume_task_handler))
|
||||
.route(ApiUrls::AUTOTASK_CANCEL, post(cancel_task_handler))
|
||||
.route(ApiUrls::AUTOTASK_TASK_SIMULATE, post(simulate_task_handler))
|
||||
.route(ApiUrls::AUTOTASK_DECISIONS, get(get_decisions_handler))
|
||||
.route(ApiUrls::AUTOTASK_DECIDE, post(submit_decision_handler))
|
||||
.route(ApiUrls::AUTOTASK_APPROVALS, get(get_approvals_handler))
|
||||
.route(ApiUrls::AUTOTASK_APPROVE, post(submit_approval_handler))
|
||||
.route(ApiUrls::AUTOTASK_TASK_EXECUTE, post(execute_task_handler))
|
||||
.route(ApiUrls::AUTOTASK_LOGS, get(get_task_logs_handler))
|
||||
.route("/api/autotask/:task_id/manifest", get(get_manifest_handler))
|
||||
.route(ApiUrls::AUTOTASK_RECOMMENDATIONS_APPLY, post(apply_recommendation_handler))
|
||||
.route(ApiUrls::AUTOTASK_PENDING, get(get_pending_items_handler))
|
||||
.route(
|
||||
&ApiUrls::AUTOTASK_PENDING_ITEM.replace(":item_id", "{item_id}"),
|
||||
post(submit_pending_item_handler),
|
||||
)
|
||||
.route(ApiUrls::AUTOTASK_PENDING_ITEM, post(submit_pending_item_handler))
|
||||
.route("/api/app-logs/client", post(handle_client_logs))
|
||||
.route("/api/app-logs/list", get(handle_list_logs))
|
||||
.route("/api/app-logs/stats", get(handle_log_stats))
|
||||
.route("/api/app-logs/clear/{app_name}", post(handle_clear_logs))
|
||||
.route("/api/app-logs/clear/:app_name", post(handle_clear_logs))
|
||||
.route("/api/app-logs/logger.js", get(handle_logger_js))
|
||||
.route("/ws/task-progress", get(task_progress_websocket_handler))
|
||||
.route("/ws/task-progress/{task_id}", get(task_progress_by_id_websocket_handler))
|
||||
.route("/ws/task-progress/:task_id", get(task_progress_by_id_websocket_handler))
|
||||
}
|
||||
|
||||
pub async fn task_progress_websocket_handler(
|
||||
|
|
|
|||
|
|
@ -28,6 +28,28 @@ pub async fn serve_vendor_file(
|
|||
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||
}
|
||||
|
||||
let content_type = get_content_type(&file_path);
|
||||
|
||||
let local_paths = [
|
||||
format!("./botui/ui/suite/js/vendor/{}", file_path),
|
||||
format!("./botserver-stack/static/js/vendor/{}", file_path),
|
||||
];
|
||||
|
||||
for local_path in &local_paths {
|
||||
if let Ok(content) = tokio::fs::read(local_path).await {
|
||||
trace!("Serving vendor file from local path: {}", local_path);
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(content))
|
||||
.unwrap_or_else(|_| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response")
|
||||
.into_response()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let bot_name = state.bucket_name
|
||||
.trim_end_matches(".gbai")
|
||||
.to_string();
|
||||
|
|
@ -36,7 +58,7 @@ pub async fn serve_vendor_file(
|
|||
let bucket = format!("{}.gbai", sanitized_bot_name);
|
||||
let key = format!("{}.gblib/vendor/{}", sanitized_bot_name, file_path);
|
||||
|
||||
info!("Serving vendor file from MinIO: bucket={}, key={}", bucket, key);
|
||||
trace!("Trying MinIO for vendor file: bucket={}, key={}", bucket, key);
|
||||
|
||||
if let Some(ref drive) = state.drive {
|
||||
match drive
|
||||
|
|
@ -50,7 +72,6 @@ pub async fn serve_vendor_file(
|
|||
match response.body.collect().await {
|
||||
Ok(body) => {
|
||||
let content = body.into_bytes();
|
||||
let content_type = get_content_type(&file_path);
|
||||
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use axum::{
|
|||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use diesel::prelude::*;
|
||||
|
|
@ -79,40 +79,10 @@ pub struct DeleteResponse {
|
|||
|
||||
pub fn configure_db_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route(
|
||||
&ApiUrls::DB_TABLE.replace(":table", "{table}"),
|
||||
get(list_records_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::DB_TABLE.replace(":table", "{table}"),
|
||||
post(create_record_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::DB_TABLE_RECORD
|
||||
.replace(":table", "{table}")
|
||||
.replace(":id", "{id}"),
|
||||
get(get_record_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::DB_TABLE_RECORD
|
||||
.replace(":table", "{table}")
|
||||
.replace(":id", "{id}"),
|
||||
put(update_record_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::DB_TABLE_RECORD
|
||||
.replace(":table", "{table}")
|
||||
.replace(":id", "{id}"),
|
||||
delete(delete_record_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::DB_TABLE_COUNT.replace(":table", "{table}"),
|
||||
get(count_records_handler),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::DB_TABLE_SEARCH.replace(":table", "{table}"),
|
||||
post(search_records_handler),
|
||||
)
|
||||
.route(ApiUrls::DB_TABLE, get(list_records_handler).post(create_record_handler))
|
||||
.route(ApiUrls::DB_TABLE_RECORD, get(get_record_handler).put(update_record_handler).delete(delete_record_handler))
|
||||
.route(ApiUrls::DB_TABLE_COUNT, get(count_records_handler))
|
||||
.route(ApiUrls::DB_TABLE_SEARCH, post(search_records_handler))
|
||||
}
|
||||
|
||||
pub async fn list_records_handler(
|
||||
|
|
|
|||
|
|
@ -521,7 +521,7 @@ pub fn configure_calendar_routes() -> Router<Arc<AppState>> {
|
|||
get(list_events).post(create_event),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::CALENDAR_EVENT_BY_ID.replace(":id", "{id}"),
|
||||
ApiUrls::CALENDAR_EVENT_BY_ID,
|
||||
get(get_event).put(update_event).delete(delete_event),
|
||||
)
|
||||
.route(ApiUrls::CALENDAR_EXPORT, get(export_ical))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod instagram;
|
||||
pub mod teams;
|
||||
pub mod telegram;
|
||||
pub mod whatsapp;
|
||||
|
||||
use crate::shared::models::BotResponse;
|
||||
|
|
|
|||
324
src/core/bot/channels/telegram.rs
Normal file
324
src/core/bot/channels/telegram.rs
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
use async_trait::async_trait;
|
||||
use diesel::prelude::*;
|
||||
use diesel::r2d2::{ConnectionManager, Pool};
|
||||
use log::{debug, error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::bot::channels::ChannelAdapter;
|
||||
use crate::core::config::ConfigManager;
|
||||
use crate::shared::models::BotResponse;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TelegramSendMessage {
|
||||
chat_id: String,
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parse_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reply_markup: Option<TelegramReplyMarkup>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TelegramReplyMarkup {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
inline_keyboard: Option<Vec<Vec<TelegramInlineButton>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
keyboard: Option<Vec<Vec<TelegramKeyboardButton>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
one_time_keyboard: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resize_keyboard: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TelegramInlineButton {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
callback_data: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TelegramKeyboardButton {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_contact: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_location: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TelegramSendPhoto {
|
||||
chat_id: String,
|
||||
photo: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
caption: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TelegramSendDocument {
|
||||
chat_id: String,
|
||||
document: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
caption: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TelegramSendLocation {
|
||||
chat_id: String,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TelegramResponse {
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TelegramAdapter {
|
||||
bot_token: String,
|
||||
}
|
||||
|
||||
impl TelegramAdapter {
|
||||
pub fn new(pool: Pool<ConnectionManager<PgConnection>>, bot_id: uuid::Uuid) -> Self {
|
||||
let config_manager = ConfigManager::new(pool);
|
||||
|
||||
let bot_token = config_manager
|
||||
.get_config(&bot_id, "telegram-bot-token", None)
|
||||
.unwrap_or_default();
|
||||
|
||||
Self { bot_token }
|
||||
}
|
||||
|
||||
async fn send_telegram_request<T: Serialize>(
|
||||
&self,
|
||||
method: &str,
|
||||
payload: &T,
|
||||
) -> Result<TelegramResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if self.bot_token.is_empty() {
|
||||
return Err("Telegram bot token not configured".into());
|
||||
}
|
||||
|
||||
let url = format!("https://api.telegram.org/bot{}/{}", self.bot_token, method);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(payload)
|
||||
.send()
|
||||
.await?
|
||||
.json::<TelegramResponse>()
|
||||
.await?;
|
||||
|
||||
if !response.ok {
|
||||
let error_msg = response
|
||||
.description
|
||||
.unwrap_or_else(|| "Unknown Telegram API error".to_string());
|
||||
error!("Telegram API error: {}", error_msg);
|
||||
return Err(error_msg.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn send_text_message(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
text: &str,
|
||||
parse_mode: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let payload = TelegramSendMessage {
|
||||
chat_id: chat_id.to_string(),
|
||||
text: text.to_string(),
|
||||
parse_mode: parse_mode.map(String::from),
|
||||
reply_markup: None,
|
||||
};
|
||||
|
||||
self.send_telegram_request("sendMessage", &payload).await?;
|
||||
info!("Telegram message sent to chat {}", chat_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_message_with_buttons(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
text: &str,
|
||||
buttons: Vec<(String, String)>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let inline_buttons: Vec<Vec<TelegramInlineButton>> = buttons
|
||||
.into_iter()
|
||||
.map(|(label, callback)| {
|
||||
vec![TelegramInlineButton {
|
||||
text: label,
|
||||
callback_data: Some(callback),
|
||||
url: None,
|
||||
}]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payload = TelegramSendMessage {
|
||||
chat_id: chat_id.to_string(),
|
||||
text: text.to_string(),
|
||||
parse_mode: Some("HTML".to_string()),
|
||||
reply_markup: Some(TelegramReplyMarkup {
|
||||
inline_keyboard: Some(inline_buttons),
|
||||
keyboard: None,
|
||||
one_time_keyboard: None,
|
||||
resize_keyboard: None,
|
||||
}),
|
||||
};
|
||||
|
||||
self.send_telegram_request("sendMessage", &payload).await?;
|
||||
info!("Telegram message with buttons sent to chat {}", chat_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_photo(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
photo_url: &str,
|
||||
caption: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let payload = TelegramSendPhoto {
|
||||
chat_id: chat_id.to_string(),
|
||||
photo: photo_url.to_string(),
|
||||
caption: caption.map(String::from),
|
||||
};
|
||||
|
||||
self.send_telegram_request("sendPhoto", &payload).await?;
|
||||
info!("Telegram photo sent to chat {}", chat_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_document(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
document_url: &str,
|
||||
caption: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let payload = TelegramSendDocument {
|
||||
chat_id: chat_id.to_string(),
|
||||
document: document_url.to_string(),
|
||||
caption: caption.map(String::from),
|
||||
};
|
||||
|
||||
self.send_telegram_request("sendDocument", &payload).await?;
|
||||
info!("Telegram document sent to chat {}", chat_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_location(
|
||||
&self,
|
||||
chat_id: &str,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let payload = TelegramSendLocation {
|
||||
chat_id: chat_id.to_string(),
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
|
||||
self.send_telegram_request("sendLocation", &payload).await?;
|
||||
info!("Telegram location sent to chat {}", chat_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_webhook(
|
||||
&self,
|
||||
webhook_url: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
#[derive(Serialize)]
|
||||
struct SetWebhook {
|
||||
url: String,
|
||||
allowed_updates: Vec<String>,
|
||||
}
|
||||
|
||||
let payload = SetWebhook {
|
||||
url: webhook_url.to_string(),
|
||||
allowed_updates: vec![
|
||||
"message".to_string(),
|
||||
"callback_query".to_string(),
|
||||
"edited_message".to_string(),
|
||||
],
|
||||
};
|
||||
|
||||
self.send_telegram_request("setWebhook", &payload).await?;
|
||||
info!("Telegram webhook set to {}", webhook_url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_webhook(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
#[derive(Serialize)]
|
||||
struct DeleteWebhook {
|
||||
drop_pending_updates: bool,
|
||||
}
|
||||
|
||||
let payload = DeleteWebhook {
|
||||
drop_pending_updates: false,
|
||||
};
|
||||
|
||||
self.send_telegram_request("deleteWebhook", &payload)
|
||||
.await?;
|
||||
info!("Telegram webhook deleted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_me(&self) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
#[derive(Serialize)]
|
||||
struct Empty {}
|
||||
|
||||
let response = self.send_telegram_request("getMe", &Empty {}).await?;
|
||||
Ok(response.result.unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for TelegramAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"Telegram"
|
||||
}
|
||||
|
||||
fn is_configured(&self) -> bool {
|
||||
!self.bot_token.is_empty()
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
&self,
|
||||
response: BotResponse,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if !self.is_configured() {
|
||||
error!("Telegram adapter not configured. Please set telegram-bot-token in config.csv");
|
||||
return Err("Telegram not configured".into());
|
||||
}
|
||||
|
||||
let chat_id = &response.user_id;
|
||||
|
||||
self.send_text_message(chat_id, &response.content, Some("HTML"))
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
"Telegram message sent to {} for session {}",
|
||||
chat_id, response.session_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_info(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(serde_json::json!({
|
||||
"id": user_id,
|
||||
"platform": "telegram",
|
||||
"chat_id": user_id
|
||||
}))
|
||||
}
|
||||
}
|
||||
921
src/core/i18n.rs
Normal file
921
src/core/i18n.rs
Normal file
|
|
@ -0,0 +1,921 @@
|
|||
use axum::{
|
||||
async_trait,
|
||||
extract::{FromRequestParts, Path, State},
|
||||
http::{header::ACCEPT_LANGUAGE, request::Parts},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use botlib::i18n::{self, Locale as BotlibLocale, MessageArgs as BotlibMessageArgs};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::shared::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Locale {
|
||||
language: String,
|
||||
region: Option<String>,
|
||||
}
|
||||
|
||||
impl Locale {
|
||||
pub fn new(locale_str: &str) -> Option<Self> {
|
||||
if locale_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = locale_str.split(&['-', '_'][..]).collect();
|
||||
|
||||
let language = parts.first()?.to_lowercase();
|
||||
if language.len() < 2 || language.len() > 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let region = parts.get(1).map(|r| r.to_uppercase());
|
||||
|
||||
Some(Self { language, region })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn language(&self) -> &str {
|
||||
&self.language
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn region(&self) -> Option<&str> {
|
||||
self.region.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn to_bcp47(&self) -> String {
|
||||
match &self.region {
|
||||
Some(r) => format!("{}-{r}", self.language),
|
||||
None => self.language.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_botlib_locale(&self) -> BotlibLocale {
|
||||
BotlibLocale::new(&self.to_bcp47()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Locale {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
language: "en".to_string(),
|
||||
region: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Locale {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.to_bcp47())
|
||||
}
|
||||
}
|
||||
|
||||
const AVAILABLE_LOCALES: &[&str] = &["en", "pt-BR", "es", "zh-CN"];
|
||||
|
||||
pub struct RequestLocale(pub Locale);
|
||||
|
||||
impl RequestLocale {
|
||||
#[must_use]
|
||||
pub fn locale(&self) -> &Locale {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn language(&self) -> &str {
|
||||
self.0.language()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for RequestLocale
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let locale = parts
|
||||
.headers
|
||||
.get(ACCEPT_LANGUAGE)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(parse_accept_language)
|
||||
.and_then(|langs| negotiate_locale(&langs))
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Self(locale))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_accept_language(header: &str) -> Vec<(String, f32)> {
|
||||
let mut langs: Vec<(String, f32)> = header
|
||||
.split(',')
|
||||
.filter_map(|part| {
|
||||
let mut iter = part.trim().split(';');
|
||||
let lang = iter.next()?.trim().to_string();
|
||||
|
||||
if lang.is_empty() || lang == "*" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let quality = iter
|
||||
.next()
|
||||
.and_then(|q| q.trim().strip_prefix("q="))
|
||||
.and_then(|q| q.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
|
||||
Some((lang, quality))
|
||||
})
|
||||
.collect();
|
||||
|
||||
langs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
langs
|
||||
}
|
||||
|
||||
fn negotiate_locale(requested: &[(String, f32)]) -> Option<Locale> {
|
||||
for (lang, _) in requested {
|
||||
let requested_locale = Locale::new(lang)?;
|
||||
|
||||
for available in AVAILABLE_LOCALES {
|
||||
let avail_locale = Locale::new(available)?;
|
||||
|
||||
if requested_locale.language == avail_locale.language
|
||||
&& requested_locale.region == avail_locale.region
|
||||
{
|
||||
return Some(avail_locale);
|
||||
}
|
||||
}
|
||||
|
||||
for available in AVAILABLE_LOCALES {
|
||||
let avail_locale = Locale::new(available)?;
|
||||
if requested_locale.language == avail_locale.language {
|
||||
return Some(avail_locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Locale::default())
|
||||
}
|
||||
|
||||
pub type MessageArgs = HashMap<String, String>;
|
||||
|
||||
pub fn init_i18n(locales_path: &str) -> Result<(), String> {
|
||||
i18n::init(locales_path).map_err(|e| format!("Failed to initialize i18n: {e}"))
|
||||
}
|
||||
|
||||
pub fn is_i18n_initialized() -> bool {
|
||||
i18n::is_initialized()
|
||||
}
|
||||
|
||||
pub fn t(locale: &Locale, key: &str) -> String {
|
||||
t_with_args(locale, key, None)
|
||||
}
|
||||
|
||||
pub fn t_with_args(locale: &Locale, key: &str, args: Option<&MessageArgs>) -> String {
|
||||
let botlib_locale = locale.to_botlib_locale();
|
||||
let botlib_args: Option<BotlibMessageArgs> = args.map(|a| {
|
||||
a.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect()
|
||||
});
|
||||
i18n::get_with_args(&botlib_locale, key, botlib_args.as_ref())
|
||||
}
|
||||
|
||||
pub fn available_locales() -> Vec<String> {
|
||||
if is_i18n_initialized() {
|
||||
i18n::available_locales()
|
||||
} else {
|
||||
AVAILABLE_LOCALES.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct LocalizedError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl LocalizedError {
|
||||
pub fn new(locale: &Locale, code: &str) -> Self {
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
message: t(locale, code),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_args(locale: &Locale, code: &str, args: &MessageArgs) -> Self {
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
message: t_with_args(locale, code, Some(args)),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(locale: &Locale, entity: &str) -> Self {
|
||||
let mut args = MessageArgs::new();
|
||||
args.insert("entity".to_string(), entity.to_string());
|
||||
Self::with_args(locale, "error-http-404", &args)
|
||||
}
|
||||
|
||||
pub fn validation(locale: &Locale, field: &str, error_key: &str) -> Self {
|
||||
let mut args = MessageArgs::new();
|
||||
args.insert("field".to_string(), field.to_string());
|
||||
Self::with_args(locale, error_key, &args)
|
||||
}
|
||||
|
||||
pub fn internal(locale: &Locale) -> Self {
|
||||
Self::new(locale, "error-http-500")
|
||||
}
|
||||
|
||||
pub fn unauthorized(locale: &Locale) -> Self {
|
||||
Self::new(locale, "error-http-401")
|
||||
}
|
||||
|
||||
pub fn forbidden(locale: &Locale) -> Self {
|
||||
Self::new(locale, "error-http-403")
|
||||
}
|
||||
|
||||
pub fn rate_limited(locale: &Locale, seconds: u64) -> Self {
|
||||
let mut args = MessageArgs::new();
|
||||
args.insert("seconds".to_string(), seconds.to_string());
|
||||
Self::with_args(locale, "error-http-429", &args)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_details(mut self, details: serde_json::Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSLATION_KEYS: &[&str] = &[
|
||||
"app-name",
|
||||
"app-tagline",
|
||||
"action-save",
|
||||
"action-cancel",
|
||||
"action-delete",
|
||||
"action-edit",
|
||||
"action-close",
|
||||
"action-confirm",
|
||||
"action-retry",
|
||||
"action-back",
|
||||
"action-next",
|
||||
"action-submit",
|
||||
"action-search",
|
||||
"action-refresh",
|
||||
"action-copy",
|
||||
"action-paste",
|
||||
"action-undo",
|
||||
"action-redo",
|
||||
"action-select",
|
||||
"action-select-all",
|
||||
"action-clear",
|
||||
"action-reset",
|
||||
"action-apply",
|
||||
"action-create",
|
||||
"action-update",
|
||||
"action-remove",
|
||||
"action-add",
|
||||
"action-upload",
|
||||
"action-download",
|
||||
"action-export",
|
||||
"action-import",
|
||||
"action-share",
|
||||
"action-send",
|
||||
"action-reply",
|
||||
"action-forward",
|
||||
"action-archive",
|
||||
"action-restore",
|
||||
"action-duplicate",
|
||||
"action-rename",
|
||||
"action-move",
|
||||
"action-filter",
|
||||
"action-sort",
|
||||
"action-view",
|
||||
"action-hide",
|
||||
"action-show",
|
||||
"action-expand",
|
||||
"action-collapse",
|
||||
"action-enable",
|
||||
"action-disable",
|
||||
"action-connect",
|
||||
"action-disconnect",
|
||||
"action-sync",
|
||||
"action-start",
|
||||
"action-stop",
|
||||
"action-pause",
|
||||
"action-resume",
|
||||
"action-continue",
|
||||
"action-finish",
|
||||
"action-complete",
|
||||
"action-approve",
|
||||
"action-reject",
|
||||
"action-accept",
|
||||
"action-decline",
|
||||
"action-login",
|
||||
"action-logout",
|
||||
"action-signup",
|
||||
"action-forgot-password",
|
||||
"label-loading",
|
||||
"label-saving",
|
||||
"label-processing",
|
||||
"label-searching",
|
||||
"label-uploading",
|
||||
"label-downloading",
|
||||
"label-no-results",
|
||||
"label-no-data",
|
||||
"label-empty",
|
||||
"label-none",
|
||||
"label-all",
|
||||
"label-selected",
|
||||
"label-required",
|
||||
"label-optional",
|
||||
"label-default",
|
||||
"label-custom",
|
||||
"label-new",
|
||||
"label-draft",
|
||||
"label-pending",
|
||||
"label-active",
|
||||
"label-inactive",
|
||||
"label-enabled",
|
||||
"label-disabled",
|
||||
"label-public",
|
||||
"label-private",
|
||||
"label-shared",
|
||||
"label-yes",
|
||||
"label-no",
|
||||
"label-on",
|
||||
"label-off",
|
||||
"label-true",
|
||||
"label-false",
|
||||
"label-unknown",
|
||||
"label-other",
|
||||
"label-more",
|
||||
"label-less",
|
||||
"label-details",
|
||||
"label-summary",
|
||||
"label-description",
|
||||
"label-name",
|
||||
"label-title",
|
||||
"label-type",
|
||||
"label-status",
|
||||
"label-priority",
|
||||
"label-date",
|
||||
"label-time",
|
||||
"label-size",
|
||||
"label-count",
|
||||
"label-total",
|
||||
"label-average",
|
||||
"label-minimum",
|
||||
"label-maximum",
|
||||
"label-version",
|
||||
"label-id",
|
||||
"label-created",
|
||||
"label-updated",
|
||||
"label-modified",
|
||||
"label-deleted",
|
||||
"label-by",
|
||||
"label-from",
|
||||
"label-to",
|
||||
"label-at",
|
||||
"label-in",
|
||||
"label-of",
|
||||
"status-success",
|
||||
"status-error",
|
||||
"status-warning",
|
||||
"status-info",
|
||||
"status-loading",
|
||||
"status-complete",
|
||||
"status-incomplete",
|
||||
"status-failed",
|
||||
"status-cancelled",
|
||||
"status-pending",
|
||||
"status-in-progress",
|
||||
"status-done",
|
||||
"status-ready",
|
||||
"status-not-ready",
|
||||
"status-connected",
|
||||
"status-disconnected",
|
||||
"status-online",
|
||||
"status-offline",
|
||||
"status-available",
|
||||
"status-unavailable",
|
||||
"status-busy",
|
||||
"status-away",
|
||||
"confirm-delete",
|
||||
"confirm-delete-item",
|
||||
"confirm-discard-changes",
|
||||
"confirm-logout",
|
||||
"confirm-cancel",
|
||||
"time-now",
|
||||
"time-today",
|
||||
"time-yesterday",
|
||||
"time-tomorrow",
|
||||
"time-this-week",
|
||||
"time-last-week",
|
||||
"time-next-week",
|
||||
"time-this-month",
|
||||
"time-last-month",
|
||||
"time-next-month",
|
||||
"time-this-year",
|
||||
"time-last-year",
|
||||
"time-next-year",
|
||||
"day-sunday",
|
||||
"day-monday",
|
||||
"day-tuesday",
|
||||
"day-wednesday",
|
||||
"day-thursday",
|
||||
"day-friday",
|
||||
"day-saturday",
|
||||
"day-sun",
|
||||
"day-mon",
|
||||
"day-tue",
|
||||
"day-wed",
|
||||
"day-thu",
|
||||
"day-fri",
|
||||
"day-sat",
|
||||
"month-january",
|
||||
"month-february",
|
||||
"month-march",
|
||||
"month-april",
|
||||
"month-may",
|
||||
"month-june",
|
||||
"month-july",
|
||||
"month-august",
|
||||
"month-september",
|
||||
"month-october",
|
||||
"month-november",
|
||||
"month-december",
|
||||
"month-jan",
|
||||
"month-feb",
|
||||
"month-mar",
|
||||
"month-apr",
|
||||
"month-may-short",
|
||||
"month-jun",
|
||||
"month-jul",
|
||||
"month-aug",
|
||||
"month-sep",
|
||||
"month-oct",
|
||||
"month-nov",
|
||||
"month-dec",
|
||||
"pagination-first",
|
||||
"pagination-previous",
|
||||
"pagination-next",
|
||||
"pagination-last",
|
||||
"pagination-items-per-page",
|
||||
"pagination-go-to-page",
|
||||
"validation-required",
|
||||
"validation-email-invalid",
|
||||
"validation-url-invalid",
|
||||
"validation-number-invalid",
|
||||
"validation-date-invalid",
|
||||
"validation-pattern-mismatch",
|
||||
"validation-passwords-mismatch",
|
||||
"a11y-skip-to-content",
|
||||
"a11y-loading",
|
||||
"a11y-menu-open",
|
||||
"a11y-menu-close",
|
||||
"a11y-expand",
|
||||
"a11y-collapse",
|
||||
"a11y-selected",
|
||||
"a11y-not-selected",
|
||||
"a11y-required",
|
||||
"a11y-error",
|
||||
"a11y-success",
|
||||
"a11y-warning",
|
||||
"a11y-info",
|
||||
"nav-home",
|
||||
"nav-chat",
|
||||
"nav-drive",
|
||||
"nav-tasks",
|
||||
"nav-mail",
|
||||
"nav-calendar",
|
||||
"nav-meet",
|
||||
"nav-paper",
|
||||
"nav-research",
|
||||
"nav-analytics",
|
||||
"nav-settings",
|
||||
"nav-admin",
|
||||
"nav-monitoring",
|
||||
"nav-sources",
|
||||
"nav-tools",
|
||||
"nav-attendant",
|
||||
"dashboard-title",
|
||||
"dashboard-welcome",
|
||||
"dashboard-quick-actions",
|
||||
"dashboard-recent-activity",
|
||||
"chat-title",
|
||||
"chat-placeholder",
|
||||
"chat-send",
|
||||
"chat-new-conversation",
|
||||
"chat-history",
|
||||
"chat-clear",
|
||||
"chat-typing",
|
||||
"chat-online",
|
||||
"chat-offline",
|
||||
"chat-connecting",
|
||||
"drive-title",
|
||||
"drive-upload",
|
||||
"drive-new-folder",
|
||||
"drive-download",
|
||||
"drive-delete",
|
||||
"drive-rename",
|
||||
"drive-move",
|
||||
"drive-copy",
|
||||
"drive-share",
|
||||
"drive-properties",
|
||||
"drive-empty-folder",
|
||||
"drive-search-placeholder",
|
||||
"drive-sort-name",
|
||||
"drive-sort-date",
|
||||
"drive-sort-size",
|
||||
"drive-sort-type",
|
||||
"tasks-title",
|
||||
"tasks-new",
|
||||
"tasks-all",
|
||||
"tasks-pending",
|
||||
"tasks-completed",
|
||||
"tasks-overdue",
|
||||
"tasks-today",
|
||||
"tasks-this-week",
|
||||
"tasks-no-tasks",
|
||||
"tasks-priority-low",
|
||||
"tasks-priority-medium",
|
||||
"tasks-priority-high",
|
||||
"tasks-priority-urgent",
|
||||
"tasks-assign",
|
||||
"tasks-due-date",
|
||||
"tasks-description",
|
||||
"calendar-title",
|
||||
"calendar-today",
|
||||
"calendar-day",
|
||||
"calendar-week",
|
||||
"calendar-month",
|
||||
"calendar-year",
|
||||
"calendar-new-event",
|
||||
"calendar-edit-event",
|
||||
"calendar-delete-event",
|
||||
"calendar-event-title",
|
||||
"calendar-event-location",
|
||||
"calendar-event-start",
|
||||
"calendar-event-end",
|
||||
"calendar-event-all-day",
|
||||
"calendar-event-repeat",
|
||||
"calendar-event-reminder",
|
||||
"calendar-no-events",
|
||||
"meet-title",
|
||||
"meet-join",
|
||||
"meet-leave",
|
||||
"meet-mute",
|
||||
"meet-unmute",
|
||||
"meet-video-on",
|
||||
"meet-video-off",
|
||||
"meet-share-screen",
|
||||
"meet-stop-sharing",
|
||||
"meet-participants",
|
||||
"meet-chat",
|
||||
"meet-settings",
|
||||
"meet-end-call",
|
||||
"meet-invite",
|
||||
"meet-copy-link",
|
||||
"email-title",
|
||||
"email-compose",
|
||||
"email-inbox",
|
||||
"email-sent",
|
||||
"email-drafts",
|
||||
"email-trash",
|
||||
"email-spam",
|
||||
"email-starred",
|
||||
"email-archive",
|
||||
"email-to",
|
||||
"email-cc",
|
||||
"email-bcc",
|
||||
"email-subject",
|
||||
"email-body",
|
||||
"email-attachments",
|
||||
"email-send",
|
||||
"email-save-draft",
|
||||
"email-discard",
|
||||
"email-reply",
|
||||
"email-reply-all",
|
||||
"email-forward",
|
||||
"email-mark-read",
|
||||
"email-mark-unread",
|
||||
"email-delete",
|
||||
"email-no-messages",
|
||||
"settings-title",
|
||||
"settings-general",
|
||||
"settings-account",
|
||||
"settings-notifications",
|
||||
"settings-privacy",
|
||||
"settings-security",
|
||||
"settings-appearance",
|
||||
"settings-language",
|
||||
"settings-timezone",
|
||||
"settings-theme",
|
||||
"settings-theme-light",
|
||||
"settings-theme-dark",
|
||||
"settings-theme-system",
|
||||
"settings-save",
|
||||
"settings-saved",
|
||||
"admin-title",
|
||||
"admin-users",
|
||||
"admin-bots",
|
||||
"admin-system",
|
||||
"admin-logs",
|
||||
"admin-backups",
|
||||
"admin-settings",
|
||||
"error-http-400",
|
||||
"error-http-401",
|
||||
"error-http-403",
|
||||
"error-http-404",
|
||||
"error-http-429",
|
||||
"error-http-500",
|
||||
"error-http-502",
|
||||
"error-http-503",
|
||||
"error-network",
|
||||
"error-timeout",
|
||||
"error-unknown",
|
||||
"paper-title",
|
||||
"paper-new-note",
|
||||
"paper-search-notes",
|
||||
"paper-quick-start",
|
||||
"paper-template-blank",
|
||||
"paper-template-meeting",
|
||||
"paper-template-todo",
|
||||
"paper-template-research",
|
||||
"paper-untitled",
|
||||
"paper-placeholder",
|
||||
"paper-commands",
|
||||
"paper-heading1",
|
||||
"paper-heading1-desc",
|
||||
"paper-heading2",
|
||||
"paper-heading2-desc",
|
||||
"paper-heading3",
|
||||
"paper-heading3-desc",
|
||||
"paper-paragraph",
|
||||
"paper-paragraph-desc",
|
||||
"paper-bullet-list",
|
||||
"paper-bullet-list-desc",
|
||||
"paper-numbered-list",
|
||||
"paper-numbered-list-desc",
|
||||
"paper-todo-list",
|
||||
"paper-todo-list-desc",
|
||||
"paper-quote",
|
||||
"paper-quote-desc",
|
||||
"paper-divider",
|
||||
"paper-divider-desc",
|
||||
"paper-code-block",
|
||||
"paper-code-block-desc",
|
||||
"paper-table",
|
||||
"paper-table-desc",
|
||||
"paper-image",
|
||||
"paper-image-desc",
|
||||
"paper-callout",
|
||||
"paper-callout-desc",
|
||||
"paper-ai-write",
|
||||
"paper-ai-write-desc",
|
||||
"paper-ai-summarize",
|
||||
"paper-ai-summarize-desc",
|
||||
"paper-ai-expand",
|
||||
"paper-ai-expand-desc",
|
||||
"paper-ai-improve",
|
||||
"paper-ai-improve-desc",
|
||||
"paper-ai-translate",
|
||||
"paper-ai-translate-desc",
|
||||
"paper-ai-assistant",
|
||||
"paper-ai-quick-actions",
|
||||
"paper-ai-rewrite",
|
||||
"paper-ai-make-shorter",
|
||||
"paper-ai-make-longer",
|
||||
"paper-ai-fix-grammar",
|
||||
"paper-ai-tone",
|
||||
"paper-ai-tone-professional",
|
||||
"paper-ai-tone-casual",
|
||||
"paper-ai-tone-friendly",
|
||||
"paper-ai-tone-formal",
|
||||
"paper-ai-translate-to",
|
||||
"paper-ai-custom-prompt",
|
||||
"paper-ai-custom-placeholder",
|
||||
"paper-ai-generate",
|
||||
"paper-ai-response",
|
||||
"paper-ai-apply",
|
||||
"paper-ai-regenerate",
|
||||
"paper-ai-copy",
|
||||
"paper-word-count",
|
||||
"paper-char-count",
|
||||
"paper-saved",
|
||||
"paper-saving",
|
||||
"paper-last-edited",
|
||||
"paper-last-edited-now",
|
||||
"paper-export",
|
||||
"paper-export-pdf",
|
||||
"paper-export-docx",
|
||||
"paper-export-markdown",
|
||||
"paper-export-html",
|
||||
"paper-export-txt",
|
||||
"chat-voice",
|
||||
"chat-message-placeholder",
|
||||
"drive-my-drive",
|
||||
"drive-shared",
|
||||
"drive-recent",
|
||||
"drive-starred",
|
||||
"drive-trash",
|
||||
"drive-loading-storage",
|
||||
"drive-storage-used",
|
||||
"drive-empty-folder",
|
||||
"drive-drop-files",
|
||||
"tasks-active",
|
||||
"tasks-awaiting",
|
||||
"tasks-paused",
|
||||
"tasks-blocked",
|
||||
"tasks-time-saved",
|
||||
"tasks-input-placeholder",
|
||||
"calendar-my-calendars",
|
||||
"email-scheduled",
|
||||
"email-tracking",
|
||||
"email-inbox",
|
||||
"email-starred",
|
||||
"email-sent",
|
||||
"email-drafts",
|
||||
"email-spam",
|
||||
"email-trash",
|
||||
"email-compose",
|
||||
"compliance-title",
|
||||
"compliance-subtitle",
|
||||
"compliance-export",
|
||||
"compliance-run-scan",
|
||||
"compliance-critical",
|
||||
"compliance-critical-desc",
|
||||
"compliance-high",
|
||||
"compliance-high-desc",
|
||||
"compliance-medium",
|
||||
"compliance-medium-desc",
|
||||
"compliance-low",
|
||||
"compliance-low-desc",
|
||||
"compliance-info",
|
||||
"compliance-info-desc",
|
||||
"compliance-filter-severity",
|
||||
"compliance-filter-type",
|
||||
"compliance-issues-found",
|
||||
"sources-title",
|
||||
"sources-subtitle",
|
||||
"sources-prompts",
|
||||
"sources-templates",
|
||||
"sources-news",
|
||||
"sources-mcp-servers",
|
||||
"sources-llm-tools",
|
||||
"sources-models",
|
||||
"sources-repositories",
|
||||
"sources-apps",
|
||||
"attendant-title",
|
||||
"attendant-subtitle",
|
||||
"attendant-queue",
|
||||
"attendant-active",
|
||||
"attendant-resolved",
|
||||
"attendant-assign",
|
||||
"attendant-transfer",
|
||||
"attendant-resolve",
|
||||
"attendant-no-items",
|
||||
"attendant-crm-disabled",
|
||||
"attendant-status-online",
|
||||
"attendant-select-conversation",
|
||||
"sources-search",
|
||||
];
|
||||
|
||||
pub fn get_translations_json(locale: &Locale) -> serde_json::Value {
|
||||
let mut translations = serde_json::Map::new();
|
||||
|
||||
for key in TRANSLATION_KEYS {
|
||||
translations.insert((*key).to_string(), serde_json::Value::String(t(locale, key)));
|
||||
}
|
||||
|
||||
serde_json::Value::Object(translations)
|
||||
}
|
||||
|
||||
pub fn configure_i18n_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/api/i18n/locales", get(handle_get_locales))
|
||||
.route("/api/i18n/:locale", get(handle_get_translations))
|
||||
}
|
||||
|
||||
async fn handle_get_locales(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let locales = available_locales();
|
||||
Json(serde_json::json!({
|
||||
"locales": locales,
|
||||
"default": "en"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_translations(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Path(locale_str): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let locale = Locale::new(&locale_str).unwrap_or_default();
|
||||
|
||||
let translations = get_translations_json(&locale);
|
||||
|
||||
Json(serde_json::json!({
|
||||
"locale": locale.to_bcp47(),
|
||||
"translations": translations
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_accept_language_simple() {
|
||||
let result = parse_accept_language("en-US");
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].0, "en-US");
|
||||
assert!((result[0].1 - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_accept_language_with_quality() {
|
||||
let result = parse_accept_language("pt-BR,pt;q=0.9,en;q=0.8");
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].0, "pt-BR");
|
||||
assert_eq!(result[1].0, "pt");
|
||||
assert_eq!(result[2].0, "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_accept_language_sorted_by_quality() {
|
||||
let result = parse_accept_language("en;q=0.5,pt-BR;q=0.9,es;q=0.7");
|
||||
assert_eq!(result[0].0, "pt-BR");
|
||||
assert_eq!(result[1].0, "es");
|
||||
assert_eq!(result[2].0, "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negotiate_locale_exact_match() {
|
||||
let requested = vec![("pt-BR".to_string(), 1.0)];
|
||||
let result = negotiate_locale(&requested);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(
|
||||
result.as_ref().map(|l| l.to_bcp47()),
|
||||
Some("pt-BR".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negotiate_locale_language_match() {
|
||||
let requested = vec![("pt-PT".to_string(), 1.0)];
|
||||
let result = negotiate_locale(&requested);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.as_ref().map(|l| l.language()), Some("pt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negotiate_locale_fallback() {
|
||||
let requested = vec![("ja".to_string(), 1.0)];
|
||||
let result = negotiate_locale(&requested);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.as_ref().map(|l| l.language()), Some("en"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_locale_default() {
|
||||
let locale = Locale::default();
|
||||
assert_eq!(locale.language(), "en");
|
||||
assert_eq!(locale.region(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_locale_display() {
|
||||
let locale = Locale::new("pt-BR").unwrap();
|
||||
assert_eq!(locale.to_string(), "pt-BR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_localized_error_not_found() {
|
||||
let locale = Locale::default();
|
||||
let error = LocalizedError::not_found(&locale, "User");
|
||||
assert_eq!(error.code, "error-http-404");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_localized_error_with_details() {
|
||||
let locale = Locale::default();
|
||||
let error =
|
||||
LocalizedError::internal(&locale).with_details(serde_json::json!({"trace_id": "abc123"}));
|
||||
assert!(error.details.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_locales_without_init() {
|
||||
let locales = available_locales();
|
||||
assert!(!locales.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,11 @@ pub mod bot_database;
|
|||
pub mod config;
|
||||
pub mod directory;
|
||||
pub mod dns;
|
||||
pub mod i18n;
|
||||
pub mod kb;
|
||||
pub mod oauth;
|
||||
pub mod package_manager;
|
||||
pub mod product;
|
||||
pub mod rate_limit;
|
||||
pub mod secrets;
|
||||
pub mod session;
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ pub struct ProviderInfo {
|
|||
pub fn configure() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/auth/oauth/providers", get(list_providers))
|
||||
.route("/auth/oauth/{provider}", get(start_oauth))
|
||||
.route("/auth/oauth/{provider}/callback", get(oauth_callback))
|
||||
.route("/auth/oauth/:provider", get(start_oauth))
|
||||
.route("/auth/oauth/:provider/callback", get(oauth_callback))
|
||||
}
|
||||
|
||||
async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
|
|
|
|||
|
|
@ -353,12 +353,14 @@ impl DirectorySetup {
|
|||
.bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new()))
|
||||
.json(&json!({
|
||||
"name": app_name,
|
||||
"redirectUris": [redirect_uri],
|
||||
"redirectUris": [redirect_uri, "http://localhost:3000/auth/callback", "http://localhost:8088/auth/callback"],
|
||||
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
|
||||
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
|
||||
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN", "OIDC_GRANT_TYPE_PASSWORD"],
|
||||
"appType": "OIDC_APP_TYPE_WEB",
|
||||
"authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC",
|
||||
"postLogoutRedirectUris": ["http://localhost:8080"],
|
||||
"authMethodType": "OIDC_AUTH_METHOD_TYPE_POST",
|
||||
"postLogoutRedirectUris": ["http://localhost:8080", "http://localhost:3000", "http://localhost:8088"],
|
||||
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
|
||||
"devMode": true,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
|
|
|||
452
src/core/product.rs
Normal file
452
src/core/product.rs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
//! Product Configuration Module
|
||||
//!
|
||||
//! This module handles white-label settings loaded from the `.product` file.
|
||||
//! It provides a global configuration that can be used throughout the application
|
||||
//! to customize branding, enabled apps, and default theme.
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Global product configuration instance
|
||||
pub static PRODUCT_CONFIG: Lazy<RwLock<ProductConfig>> = Lazy::new(|| {
|
||||
RwLock::new(ProductConfig::load().unwrap_or_default())
|
||||
});
|
||||
|
||||
/// Product configuration structure
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProductConfig {
|
||||
/// Product name (replaces "General Bots" throughout the application)
|
||||
pub name: String,
|
||||
|
||||
/// Set of active apps
|
||||
pub apps: HashSet<String>,
|
||||
|
||||
/// Default theme
|
||||
pub theme: String,
|
||||
|
||||
/// Logo URL (optional)
|
||||
pub logo: Option<String>,
|
||||
|
||||
/// Favicon URL (optional)
|
||||
pub favicon: Option<String>,
|
||||
|
||||
/// Primary color override (optional)
|
||||
pub primary_color: Option<String>,
|
||||
|
||||
/// Support email (optional)
|
||||
pub support_email: Option<String>,
|
||||
|
||||
/// Documentation URL (optional)
|
||||
pub docs_url: Option<String>,
|
||||
|
||||
/// Copyright text (optional)
|
||||
pub copyright: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ProductConfig {
|
||||
fn default() -> Self {
|
||||
let mut apps = HashSet::new();
|
||||
// All apps enabled by default
|
||||
for app in &[
|
||||
"chat", "mail", "calendar", "drive", "tasks", "docs", "paper",
|
||||
"sheet", "slides", "meet", "research", "sources", "analytics",
|
||||
"admin", "monitoring", "settings",
|
||||
] {
|
||||
apps.insert(app.to_string());
|
||||
}
|
||||
|
||||
Self {
|
||||
name: "General Bots".to_string(),
|
||||
apps,
|
||||
theme: "sentient".to_string(),
|
||||
logo: None,
|
||||
favicon: None,
|
||||
primary_color: None,
|
||||
support_email: None,
|
||||
docs_url: Some("https://docs.pragmatismo.com.br".to_string()),
|
||||
copyright: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProductConfig {
|
||||
/// Load configuration from .product file
|
||||
pub fn load() -> Result<Self, ProductConfigError> {
|
||||
let paths = [
|
||||
".product",
|
||||
"./botserver/.product",
|
||||
"../.product",
|
||||
];
|
||||
|
||||
let mut content = None;
|
||||
for path in &paths {
|
||||
if Path::new(path).exists() {
|
||||
content = Some(fs::read_to_string(path).map_err(ProductConfigError::IoError)?);
|
||||
info!("Loaded product configuration from: {}", path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let content = match content {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
warn!("No .product file found, using default configuration");
|
||||
return Ok(Self::default());
|
||||
}
|
||||
};
|
||||
|
||||
Self::parse(&content)
|
||||
}
|
||||
|
||||
/// Parse configuration from string content
|
||||
pub fn parse(content: &str) -> Result<Self, ProductConfigError> {
|
||||
let mut config = Self::default();
|
||||
let mut apps_specified = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse key=value pairs
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let key = key.trim().to_lowercase();
|
||||
let value = value.trim();
|
||||
|
||||
match key.as_str() {
|
||||
"name" => {
|
||||
if !value.is_empty() {
|
||||
config.name = value.to_string();
|
||||
}
|
||||
}
|
||||
"apps" => {
|
||||
apps_specified = true;
|
||||
config.apps.clear();
|
||||
for app in value.split(',') {
|
||||
let app = app.trim().to_lowercase();
|
||||
if !app.is_empty() {
|
||||
config.apps.insert(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
"theme" => {
|
||||
if !value.is_empty() {
|
||||
config.theme = value.to_string();
|
||||
}
|
||||
}
|
||||
"logo" => {
|
||||
if !value.is_empty() {
|
||||
config.logo = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
"favicon" => {
|
||||
if !value.is_empty() {
|
||||
config.favicon = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
"primary_color" => {
|
||||
if !value.is_empty() {
|
||||
config.primary_color = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
"support_email" => {
|
||||
if !value.is_empty() {
|
||||
config.support_email = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
"docs_url" => {
|
||||
if !value.is_empty() {
|
||||
config.docs_url = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
"copyright" => {
|
||||
if !value.is_empty() {
|
||||
config.copyright = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown product configuration key: {}", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !apps_specified {
|
||||
info!("No apps specified in .product, all apps enabled by default");
|
||||
}
|
||||
|
||||
info!(
|
||||
"Product config loaded: name='{}', apps={:?}, theme='{}'",
|
||||
config.name, config.apps, config.theme
|
||||
);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Check if an app is enabled
|
||||
pub fn is_app_enabled(&self, app: &str) -> bool {
|
||||
self.apps.contains(&app.to_lowercase())
|
||||
}
|
||||
|
||||
/// Get the product name
|
||||
pub fn get_name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Get the default theme
|
||||
pub fn get_theme(&self) -> &str {
|
||||
&self.theme
|
||||
}
|
||||
|
||||
/// Replace "General Bots" with the product name in a string
|
||||
pub fn replace_branding(&self, text: &str) -> String {
|
||||
text.replace("General Bots", &self.name)
|
||||
.replace("general bots", &self.name.to_lowercase())
|
||||
.replace("GENERAL BOTS", &self.name.to_uppercase())
|
||||
}
|
||||
|
||||
/// Get copyright text with year substitution
|
||||
pub fn get_copyright(&self) -> String {
|
||||
let year = chrono::Utc::now().format("%Y").to_string();
|
||||
let template = self.copyright.as_deref()
|
||||
.unwrap_or("© {year} {name}. All rights reserved.");
|
||||
|
||||
template
|
||||
.replace("{year}", &year)
|
||||
.replace("{name}", &self.name)
|
||||
}
|
||||
|
||||
/// Get all enabled apps as a vector
|
||||
pub fn get_enabled_apps(&self) -> Vec<String> {
|
||||
self.apps.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Reload configuration from file
|
||||
pub fn reload() -> Result<(), ProductConfigError> {
|
||||
let new_config = Self::load()?;
|
||||
let mut config = PRODUCT_CONFIG.write()
|
||||
.map_err(|_| ProductConfigError::LockError)?;
|
||||
*config = new_config;
|
||||
info!("Product configuration reloaded");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for product configuration
|
||||
#[derive(Debug)]
|
||||
pub enum ProductConfigError {
|
||||
IoError(std::io::Error),
|
||||
ParseError(String),
|
||||
LockError,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProductConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::IoError(e) => write!(f, "IO error reading .product file: {}", e),
|
||||
Self::ParseError(msg) => write!(f, "Parse error in .product file: {}", msg),
|
||||
Self::LockError => write!(f, "Failed to acquire lock on product configuration"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProductConfigError {}
|
||||
|
||||
/// Helper function to get product name
|
||||
pub fn get_product_name() -> String {
|
||||
PRODUCT_CONFIG
|
||||
.read()
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_else(|_| "General Bots".to_string())
|
||||
}
|
||||
|
||||
/// Helper function to check if an app is enabled
|
||||
pub fn is_app_enabled(app: &str) -> bool {
|
||||
PRODUCT_CONFIG
|
||||
.read()
|
||||
.map(|c| c.is_app_enabled(app))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Helper function to get default theme
|
||||
pub fn get_default_theme() -> String {
|
||||
PRODUCT_CONFIG
|
||||
.read()
|
||||
.map(|c| c.theme.clone())
|
||||
.unwrap_or_else(|_| "sentient".to_string())
|
||||
}
|
||||
|
||||
/// Helper function to replace branding in text
|
||||
pub fn replace_branding(text: &str) -> String {
|
||||
PRODUCT_CONFIG
|
||||
.read()
|
||||
.map(|c| c.replace_branding(text))
|
||||
.unwrap_or_else(|_| text.to_string())
|
||||
}
|
||||
|
||||
/// Helper function to get product config for serialization
|
||||
pub fn get_product_config_json() -> serde_json::Value {
|
||||
let config = PRODUCT_CONFIG.read().ok();
|
||||
|
||||
match config {
|
||||
Some(c) => serde_json::json!({
|
||||
"name": c.name,
|
||||
"apps": c.get_enabled_apps(),
|
||||
"theme": c.theme,
|
||||
"logo": c.logo,
|
||||
"favicon": c.favicon,
|
||||
"primary_color": c.primary_color,
|
||||
"docs_url": c.docs_url,
|
||||
"copyright": c.get_copyright(),
|
||||
}),
|
||||
None => serde_json::json!({
|
||||
"name": "General Bots",
|
||||
"apps": [],
|
||||
"theme": "sentient",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware to check if an app is enabled before allowing API access
|
||||
pub async fn app_gate_middleware(
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let path = req.uri().path();
|
||||
|
||||
// Map API paths to app names
|
||||
let app_name = match path {
|
||||
p if p.starts_with("/api/calendar") => Some("calendar"),
|
||||
p if p.starts_with("/api/mail") || p.starts_with("/api/email") => Some("mail"),
|
||||
p if p.starts_with("/api/drive") || p.starts_with("/api/files") => Some("drive"),
|
||||
p if p.starts_with("/api/tasks") => Some("tasks"),
|
||||
p if p.starts_with("/api/docs") => Some("docs"),
|
||||
p if p.starts_with("/api/paper") => Some("paper"),
|
||||
p if p.starts_with("/api/sheet") => Some("sheet"),
|
||||
p if p.starts_with("/api/slides") => Some("slides"),
|
||||
p if p.starts_with("/api/meet") => Some("meet"),
|
||||
p if p.starts_with("/api/research") => Some("research"),
|
||||
p if p.starts_with("/api/sources") => Some("sources"),
|
||||
p if p.starts_with("/api/analytics") => Some("analytics"),
|
||||
p if p.starts_with("/api/admin") => Some("admin"),
|
||||
p if p.starts_with("/api/monitoring") => Some("monitoring"),
|
||||
p if p.starts_with("/api/settings") => Some("settings"),
|
||||
p if p.starts_with("/api/ui/calendar") => Some("calendar"),
|
||||
p if p.starts_with("/api/ui/mail") => Some("mail"),
|
||||
p if p.starts_with("/api/ui/drive") => Some("drive"),
|
||||
p if p.starts_with("/api/ui/tasks") => Some("tasks"),
|
||||
p if p.starts_with("/api/ui/docs") => Some("docs"),
|
||||
p if p.starts_with("/api/ui/paper") => Some("paper"),
|
||||
p if p.starts_with("/api/ui/sheet") => Some("sheet"),
|
||||
p if p.starts_with("/api/ui/slides") => Some("slides"),
|
||||
p if p.starts_with("/api/ui/meet") => Some("meet"),
|
||||
p if p.starts_with("/api/ui/research") => Some("research"),
|
||||
p if p.starts_with("/api/ui/sources") => Some("sources"),
|
||||
p if p.starts_with("/api/ui/analytics") => Some("analytics"),
|
||||
p if p.starts_with("/api/ui/admin") => Some("admin"),
|
||||
p if p.starts_with("/api/ui/monitoring") => Some("monitoring"),
|
||||
p if p.starts_with("/api/ui/settings") => Some("settings"),
|
||||
_ => None, // Allow all other paths
|
||||
};
|
||||
|
||||
// Check if the app is enabled
|
||||
if let Some(app) = app_name {
|
||||
if !is_app_enabled(app) {
|
||||
let error_response = serde_json::json!({
|
||||
"error": "app_disabled",
|
||||
"message": format!("The '{}' app is not enabled for this installation", app),
|
||||
"code": 403
|
||||
});
|
||||
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::Json(error_response)
|
||||
).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Get list of disabled apps for logging/debugging
|
||||
pub fn get_disabled_apps() -> Vec<String> {
|
||||
let all_apps = vec![
|
||||
"chat", "mail", "calendar", "drive", "tasks", "docs", "paper",
|
||||
"sheet", "slides", "meet", "research", "sources", "analytics",
|
||||
"admin", "monitoring", "settings",
|
||||
];
|
||||
|
||||
all_apps
|
||||
.into_iter()
|
||||
.filter(|app| !is_app_enabled(app))
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = ProductConfig::default();
|
||||
assert_eq!(config.name, "General Bots");
|
||||
assert_eq!(config.theme, "sentient");
|
||||
assert!(config.is_app_enabled("chat"));
|
||||
assert!(config.is_app_enabled("drive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config() {
|
||||
let content = r#"
|
||||
# Test config
|
||||
name=My Custom Bot
|
||||
apps=chat,drive,tasks
|
||||
theme=dark
|
||||
"#;
|
||||
|
||||
let config = ProductConfig::parse(content).unwrap();
|
||||
assert_eq!(config.name, "My Custom Bot");
|
||||
assert_eq!(config.theme, "dark");
|
||||
assert!(config.is_app_enabled("chat"));
|
||||
assert!(config.is_app_enabled("drive"));
|
||||
assert!(config.is_app_enabled("tasks"));
|
||||
assert!(!config.is_app_enabled("mail"));
|
||||
assert!(!config.is_app_enabled("calendar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_branding() {
|
||||
let config = ProductConfig {
|
||||
name: "Acme Bot".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.replace_branding("Welcome to General Bots"),
|
||||
"Welcome to Acme Bot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive_apps() {
|
||||
let content = "apps=Chat,DRIVE,Tasks";
|
||||
let config = ProductConfig::parse(content).unwrap();
|
||||
|
||||
assert!(config.is_app_enabled("chat"));
|
||||
assert!(config.is_app_enabled("CHAT"));
|
||||
assert!(config.is_app_enabled("Chat"));
|
||||
assert!(config.is_app_enabled("drive"));
|
||||
assert!(config.is_app_enabled("tasks"));
|
||||
}
|
||||
}
|
||||
|
|
@ -224,7 +224,7 @@ impl SecretsManager {
|
|||
Ok((
|
||||
s.get("url")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "https://localhost:8080".into()),
|
||||
.unwrap_or_else(|| "http://localhost:8300".into()),
|
||||
s.get("project_id").cloned().unwrap_or_default(),
|
||||
s.get("client_id").cloned().unwrap_or_default(),
|
||||
s.get("client_secret").cloned().unwrap_or_default(),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
response::{Html, Json},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::urls::ApiUrls;
|
||||
use crate::shared::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -194,7 +197,505 @@ pub struct SuccessResponse {
|
|||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn get_system_status(
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AdminDashboardData {
|
||||
pub total_users: i64,
|
||||
pub active_groups: i64,
|
||||
pub running_bots: i64,
|
||||
pub storage_used_gb: f64,
|
||||
pub storage_total_gb: f64,
|
||||
pub recent_activity: Vec<ActivityItem>,
|
||||
pub system_health: SystemHealth,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActivityItem {
|
||||
pub id: String,
|
||||
pub action: String,
|
||||
pub user: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SystemHealth {
|
||||
pub status: String,
|
||||
pub cpu_percent: f64,
|
||||
pub memory_percent: f64,
|
||||
pub services_healthy: i32,
|
||||
pub services_total: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatValue {
|
||||
pub value: String,
|
||||
pub label: String,
|
||||
pub trend: Option<String>,
|
||||
}
|
||||
|
||||
pub fn configure() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route(ApiUrls::ADMIN_DASHBOARD, get(get_admin_dashboard))
|
||||
.route(ApiUrls::ADMIN_STATS_USERS, get(get_stats_users))
|
||||
.route(ApiUrls::ADMIN_STATS_GROUPS, get(get_stats_groups))
|
||||
.route(ApiUrls::ADMIN_STATS_BOTS, get(get_stats_bots))
|
||||
.route(ApiUrls::ADMIN_STATS_STORAGE, get(get_stats_storage))
|
||||
.route(ApiUrls::ADMIN_USERS, get(get_admin_users))
|
||||
.route(ApiUrls::ADMIN_GROUPS, get(get_admin_groups))
|
||||
.route(ApiUrls::ADMIN_BOTS, get(get_admin_bots))
|
||||
.route(ApiUrls::ADMIN_DNS, get(get_admin_dns))
|
||||
.route(ApiUrls::ADMIN_BILLING, get(get_admin_billing))
|
||||
.route(ApiUrls::ADMIN_AUDIT, get(get_admin_audit))
|
||||
.route(ApiUrls::ADMIN_SYSTEM, get(get_system_status))
|
||||
}
|
||||
|
||||
pub async fn get_admin_dashboard(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="dashboard-view">
|
||||
<div class="page-header">
|
||||
<h1 data-i18n="admin-dashboard-title">Dashboard</h1>
|
||||
<p class="subtitle" data-i18n="admin-dashboard-subtitle">System overview and quick statistics</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"
|
||||
hx-get="/api/admin/stats/users"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="loading-state"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="stat-card"
|
||||
hx-get="/api/admin/stats/groups"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="loading-state"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="stat-card"
|
||||
hx-get="/api/admin/stats/bots"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="loading-state"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="stat-card"
|
||||
hx-get="/api/admin/stats/storage"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="loading-state"><div class="spinner"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 data-i18n="admin-quick-actions">Quick Actions</h2>
|
||||
<div class="quick-actions-grid">
|
||||
<button class="action-card" onclick="document.getElementById('create-user-modal').showModal()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="8.5" cy="7" r="4"></circle>
|
||||
<line x1="20" y1="8" x2="20" y2="14"></line>
|
||||
<line x1="23" y1="11" x2="17" y2="11"></line>
|
||||
</svg>
|
||||
<span data-i18n="admin-add-user">Add User</span>
|
||||
</button>
|
||||
<button class="action-card" onclick="document.getElementById('create-group-modal').showModal()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<line x1="23" y1="11" x2="17" y2="11"></line>
|
||||
<line x1="20" y1="8" x2="20" y2="14"></line>
|
||||
</svg>
|
||||
<span data-i18n="admin-add-group">Add Group</span>
|
||||
</button>
|
||||
<button class="action-card" hx-get="/api/admin/audit" hx-target="#admin-content" hx-swap="innerHTML">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<span data-i18n="admin-view-audit">View Audit Log</span>
|
||||
</button>
|
||||
<button class="action-card" hx-get="/api/admin/billing" hx-target="#admin-content" hx-swap="innerHTML">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="1" y1="10" x2="23" y2="10"></line>
|
||||
</svg>
|
||||
<span data-i18n="admin-billing">Billing</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 data-i18n="admin-system-health">System Health</h2>
|
||||
<div class="health-grid">
|
||||
<div class="health-card">
|
||||
<div class="health-card-header">
|
||||
<span class="health-card-title">API Server</span>
|
||||
<span class="health-status healthy">Healthy</span>
|
||||
</div>
|
||||
<div class="health-value">99.9%</div>
|
||||
<div class="health-label">Uptime</div>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<div class="health-card-header">
|
||||
<span class="health-card-title">Database</span>
|
||||
<span class="health-status healthy">Healthy</span>
|
||||
</div>
|
||||
<div class="health-value">12ms</div>
|
||||
<div class="health-label">Avg Response</div>
|
||||
</div>
|
||||
<div class="health-card">
|
||||
<div class="health-card-header">
|
||||
<span class="health-card-title">Storage</span>
|
||||
<span class="health-status healthy">Healthy</span>
|
||||
</div>
|
||||
<div class="health-value">45%</div>
|
||||
<div class="health-label">Capacity Used</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_stats_users(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="stat-icon users">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-value">127</span>
|
||||
<span class="stat-label" data-i18n="admin-total-users">Total Users</span>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_stats_groups(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="stat-icon groups">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<circle cx="19" cy="11" r="2"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-value">12</span>
|
||||
<span class="stat-label" data-i18n="admin-active-groups">Active Groups</span>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_stats_bots(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="stat-icon bots">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="10" rx="2"></rect>
|
||||
<circle cx="12" cy="5" r="2"></circle>
|
||||
<path d="M12 7v4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-value">8</span>
|
||||
<span class="stat-label" data-i18n="admin-running-bots">Running Bots</span>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_stats_storage(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="stat-icon storage">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-value">45.2 GB</span>
|
||||
<span class="stat-label" data-i18n="admin-storage-used">Storage Used</span>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_admin_users(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="admin-page">
|
||||
<div class="page-header">
|
||||
<h1 data-i18n="admin-users">Users</h1>
|
||||
<p class="subtitle" data-i18n="admin-users-subtitle">Manage user accounts and permissions</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn-primary" onclick="document.getElementById('create-user-modal').showModal()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>John Doe</td>
|
||||
<td>john@example.com</td>
|
||||
<td><span class="badge badge-admin">Admin</span></td>
|
||||
<td><span class="status status-active">Active</span></td>
|
||||
<td><button class="btn-icon">Edit</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Jane Smith</td>
|
||||
<td>jane@example.com</td>
|
||||
<td><span class="badge badge-user">User</span></td>
|
||||
<td><span class="status status-active">Active</span></td>
|
||||
<td><button class="btn-icon">Edit</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_admin_groups(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="admin-page">
|
||||
<div class="page-header">
|
||||
<h1 data-i18n="admin-groups">Groups</h1>
|
||||
<p class="subtitle" data-i18n="admin-groups-subtitle">Manage groups and team permissions</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn-primary" onclick="document.getElementById('create-group-modal').showModal()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add Group
|
||||
</button>
|
||||
</div>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Members</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Engineering</td>
|
||||
<td>15</td>
|
||||
<td>2024-01-15</td>
|
||||
<td><button class="btn-icon">Manage</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Marketing</td>
|
||||
<td>8</td>
|
||||
<td>2024-02-20</td>
|
||||
<td><button class="btn-icon">Manage</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_admin_bots(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="admin-page">
|
||||
<div class="page-header">
|
||||
<h1 data-i18n="admin-bots">Bots</h1>
|
||||
<p class="subtitle" data-i18n="admin-bots-subtitle">Manage bot instances and deployments</p>
|
||||
</div>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Messages</th>
|
||||
<th>Last Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Support Bot</td>
|
||||
<td><span class="status status-active">Running</span></td>
|
||||
<td>1,234</td>
|
||||
<td>Just now</td>
|
||||
<td><button class="btn-icon">Configure</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sales Assistant</td>
|
||||
<td><span class="status status-active">Running</span></td>
|
||||
<td>567</td>
|
||||
<td>5 min ago</td>
|
||||
<td><button class="btn-icon">Configure</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_admin_dns(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="admin-page">
|
||||
<div class="page-header">
|
||||
<h1 data-i18n="admin-dns">DNS Management</h1>
|
||||
<p class="subtitle" data-i18n="admin-dns-subtitle">Configure custom domains and DNS settings</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn-primary" onclick="document.getElementById('add-dns-modal').showModal()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add Domain
|
||||
</button>
|
||||
</div>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>SSL</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>bot.example.com</td>
|
||||
<td>CNAME</td>
|
||||
<td><span class="status status-active">Active</span></td>
|
||||
<td><span class="status status-active">Valid</span></td>
|
||||
<td><button class="btn-icon">Edit</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_admin_billing(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let html = r##"
|
||||
<div class="admin-page">
|
||||
<div class="page-header">
|
||||
<h1 data-i18n="admin-billing">Billing</h1>
|
||||
<p class="subtitle" data-i18n="admin-billing-subtitle">Manage subscription and payment settings</p>
|
||||
</div>
|
||||
<div class="billing-overview">
|
||||
<div class="billing-card">
|
||||
<h3>Current Plan</h3>
|
||||
<div class="plan-name">Enterprise</div>
|
||||
<div class="plan-price">$499/month</div>
|
||||
</div>
|
||||
<div class="billing-card">
|
||||
<h3>Next Billing Date</h3>
|
||||
<div class="billing-date">January 15, 2025</div>
|
||||
</div>
|
||||
<div class="billing-card">
|
||||
<h3>Payment Method</h3>
|
||||
<div class="payment-method">**** **** **** 4242</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"##;
|
||||
Html(html.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_admin_audit(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Html<String> {
|
||||
let now = Utc::now();
|
||||
let html = format!(r##"
|
||||
<div class="admin-page">
|
||||
<div class="page-header">
|
||||
<h1 data-i18n="admin-audit">Audit Log</h1>
|
||||
<p class="subtitle" data-i18n="admin-audit-subtitle">Track system events and user actions</p>
|
||||
</div>
|
||||
<div class="data-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{}</td>
|
||||
<td>admin@example.com</td>
|
||||
<td>User Login</td>
|
||||
<td>Successful login from 192.168.1.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{}</td>
|
||||
<td>admin@example.com</td>
|
||||
<td>Settings Changed</td>
|
||||
<td>Updated system configuration</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"##, now.format("%Y-%m-%d %H:%M"), now.format("%Y-%m-%d %H:%M"));
|
||||
Html(html)
|
||||
}
|
||||
|
||||
pub async fn get_system_status(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Result<Json<SystemStatusResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let now = Utc::now();
|
||||
|
|
@ -259,7 +760,7 @@ pub fn get_system_status(
|
|||
Ok(Json(status))
|
||||
}
|
||||
|
||||
pub fn get_system_metrics(
|
||||
pub async fn get_system_metrics(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Result<Json<SystemMetricsResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let metrics = SystemMetricsResponse {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ impl ApiUrls {
|
|||
pub const GROUP_REMOVE_MEMBER: &'static str = "/api/groups/:id/members/:user_id";
|
||||
pub const GROUP_PERMISSIONS: &'static str = "/api/groups/:id/permissions";
|
||||
|
||||
// Product - JSON APIs
|
||||
pub const PRODUCT: &'static str = "/api/product";
|
||||
|
||||
// Auth - JSON APIs
|
||||
pub const AUTH: &'static str = "/api/auth";
|
||||
pub const AUTH_TOKEN: &'static str = "/api/auth/token";
|
||||
|
|
@ -162,8 +165,17 @@ impl ApiUrls {
|
|||
pub const ANALYTICS_BUDGET_STATUS: &'static str = "/api/ui/analytics/budget/status";
|
||||
|
||||
// Admin - JSON APIs
|
||||
pub const ADMIN_DASHBOARD: &'static str = "/api/admin/dashboard";
|
||||
pub const ADMIN_STATS: &'static str = "/api/admin/stats";
|
||||
pub const ADMIN_STATS_USERS: &'static str = "/api/admin/stats/users";
|
||||
pub const ADMIN_STATS_GROUPS: &'static str = "/api/admin/stats/groups";
|
||||
pub const ADMIN_STATS_BOTS: &'static str = "/api/admin/stats/bots";
|
||||
pub const ADMIN_STATS_STORAGE: &'static str = "/api/admin/stats/storage";
|
||||
pub const ADMIN_USERS: &'static str = "/api/admin/users";
|
||||
pub const ADMIN_GROUPS: &'static str = "/api/admin/groups";
|
||||
pub const ADMIN_BOTS: &'static str = "/api/admin/bots";
|
||||
pub const ADMIN_DNS: &'static str = "/api/admin/dns";
|
||||
pub const ADMIN_BILLING: &'static str = "/api/admin/billing";
|
||||
pub const ADMIN_SYSTEM: &'static str = "/api/admin/system";
|
||||
pub const ADMIN_LOGS: &'static str = "/api/admin/logs";
|
||||
pub const ADMIN_BACKUPS: &'static str = "/api/admin/backups";
|
||||
|
|
@ -175,6 +187,10 @@ impl ApiUrls {
|
|||
pub const STATUS: &'static str = "/api/status";
|
||||
pub const SERVICES_STATUS: &'static str = "/api/services/status";
|
||||
|
||||
// i18n - JSON APIs
|
||||
pub const I18N_TRANSLATIONS: &'static str = "/api/i18n/:locale";
|
||||
pub const I18N_LOCALES: &'static str = "/api/i18n/locales";
|
||||
|
||||
// Knowledge Base - JSON APIs
|
||||
pub const KB_SEARCH: &'static str = "/api/kb/search";
|
||||
pub const KB_UPLOAD: &'static str = "/api/kb/upload";
|
||||
|
|
@ -278,7 +294,32 @@ impl ApiUrls {
|
|||
pub const MSTEAMS_MESSAGES: &'static str = "/api/msteams/messages";
|
||||
pub const MSTEAMS_SEND: &'static str = "/api/msteams/send";
|
||||
|
||||
// Paper - HTMX/HTML APIs
|
||||
// Docs (Word Processor) - HTMX/HTML APIs
|
||||
pub const DOCS_NEW: &'static str = "/api/ui/docs/new";
|
||||
pub const DOCS_LIST: &'static str = "/api/ui/docs/list";
|
||||
pub const DOCS_SEARCH: &'static str = "/api/ui/docs/search";
|
||||
pub const DOCS_SAVE: &'static str = "/api/ui/docs/save";
|
||||
pub const DOCS_AUTOSAVE: &'static str = "/api/ui/docs/autosave";
|
||||
pub const DOCS_BY_ID: &'static str = "/api/ui/docs/:id";
|
||||
pub const DOCS_DELETE: &'static str = "/api/ui/docs/:id/delete";
|
||||
pub const DOCS_TEMPLATE_BLANK: &'static str = "/api/ui/docs/template/blank";
|
||||
pub const DOCS_TEMPLATE_MEETING: &'static str = "/api/ui/docs/template/meeting";
|
||||
pub const DOCS_TEMPLATE_REPORT: &'static str = "/api/ui/docs/template/report";
|
||||
pub const DOCS_TEMPLATE_LETTER: &'static str = "/api/ui/docs/template/letter";
|
||||
pub const DOCS_AI_SUMMARIZE: &'static str = "/api/ui/docs/ai/summarize";
|
||||
pub const DOCS_AI_EXPAND: &'static str = "/api/ui/docs/ai/expand";
|
||||
pub const DOCS_AI_IMPROVE: &'static str = "/api/ui/docs/ai/improve";
|
||||
pub const DOCS_AI_SIMPLIFY: &'static str = "/api/ui/docs/ai/simplify";
|
||||
pub const DOCS_AI_TRANSLATE: &'static str = "/api/ui/docs/ai/translate";
|
||||
pub const DOCS_AI_CUSTOM: &'static str = "/api/ui/docs/ai/custom";
|
||||
pub const DOCS_EXPORT_PDF: &'static str = "/api/ui/docs/export/pdf";
|
||||
pub const DOCS_EXPORT_DOCX: &'static str = "/api/ui/docs/export/docx";
|
||||
pub const DOCS_EXPORT_MD: &'static str = "/api/ui/docs/export/md";
|
||||
pub const DOCS_EXPORT_HTML: &'static str = "/api/ui/docs/export/html";
|
||||
pub const DOCS_EXPORT_TXT: &'static str = "/api/ui/docs/export/txt";
|
||||
pub const DOCS_WS: &'static str = "/ws/docs/:doc_id";
|
||||
|
||||
// Paper (Notes App) - HTMX/HTML APIs
|
||||
pub const PAPER_NEW: &'static str = "/api/ui/paper/new";
|
||||
pub const PAPER_LIST: &'static str = "/api/ui/paper/list";
|
||||
pub const PAPER_SEARCH: &'static str = "/api/ui/paper/search";
|
||||
|
|
@ -290,6 +331,8 @@ impl ApiUrls {
|
|||
pub const PAPER_TEMPLATE_MEETING: &'static str = "/api/ui/paper/template/meeting";
|
||||
pub const PAPER_TEMPLATE_TODO: &'static str = "/api/ui/paper/template/todo";
|
||||
pub const PAPER_TEMPLATE_RESEARCH: &'static str = "/api/ui/paper/template/research";
|
||||
pub const PAPER_TEMPLATE_REPORT: &'static str = "/api/ui/paper/template/report";
|
||||
pub const PAPER_TEMPLATE_LETTER: &'static str = "/api/ui/paper/template/letter";
|
||||
pub const PAPER_AI_SUMMARIZE: &'static str = "/api/ui/paper/ai/summarize";
|
||||
pub const PAPER_AI_EXPAND: &'static str = "/api/ui/paper/ai/expand";
|
||||
pub const PAPER_AI_IMPROVE: &'static str = "/api/ui/paper/ai/improve";
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ pub struct ValidateRequest {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileQuery {
|
||||
pub path: Option<String>,
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, QueryableByName)]
|
||||
|
|
@ -114,7 +115,7 @@ pub fn configure_designer_routes() -> Router<Arc<AppState>> {
|
|||
ApiUrls::DESIGNER_DIALOGS,
|
||||
get(handle_list_dialogs).post(handle_create_dialog),
|
||||
)
|
||||
.route(&ApiUrls::DESIGNER_DIALOG_BY_ID.replace(":id", "{id}"), get(handle_get_dialog))
|
||||
.route(ApiUrls::DESIGNER_DIALOG_BY_ID, get(handle_get_dialog))
|
||||
.route(ApiUrls::DESIGNER_MODIFY, post(handle_designer_modify))
|
||||
.route("/api/ui/designer/magic", post(handle_magic_suggestions))
|
||||
.route("/api/ui/editor/magic", post(handle_editor_magic))
|
||||
|
|
@ -396,8 +397,19 @@ pub async fn handle_load_file(
|
|||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<FileQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let file_id = params.path.unwrap_or_else(|| "welcome".to_string());
|
||||
let file_path = params.path.unwrap_or_else(|| "welcome".to_string());
|
||||
|
||||
let content = if let Some(bucket) = params.bucket {
|
||||
match load_from_drive(&state, &bucket, &file_path).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("Failed to load file from drive: {}", e);
|
||||
get_default_dialog_content()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let conn = state.conn.clone();
|
||||
let file_id = file_path;
|
||||
|
||||
let dialog = tokio::task::spawn_blocking(move || {
|
||||
let mut db_conn = match conn.get() {
|
||||
|
|
@ -418,9 +430,10 @@ pub async fn handle_load_file(
|
|||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let content = match dialog {
|
||||
match dialog {
|
||||
Some(d) => d.content,
|
||||
None => get_default_dialog_content(),
|
||||
}
|
||||
};
|
||||
|
||||
let mut html = String::new();
|
||||
|
|
@ -850,6 +863,34 @@ fn validate_basic_code(code: &str) -> ValidationResult {
|
|||
}
|
||||
}
|
||||
|
||||
async fn load_from_drive(
|
||||
state: &Arc<AppState>,
|
||||
bucket: &str,
|
||||
path: &str,
|
||||
) -> Result<String, String> {
|
||||
let s3_client = state
|
||||
.drive
|
||||
.as_ref()
|
||||
.ok_or_else(|| "S3 service not available".to_string())?;
|
||||
|
||||
let result = s3_client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read file from drive: {e}"))?;
|
||||
|
||||
let bytes = result
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read file body: {e}"))?
|
||||
.into_bytes();
|
||||
|
||||
String::from_utf8(bytes.to_vec()).map_err(|e| format!("File is not valid UTF-8: {e}"))
|
||||
}
|
||||
|
||||
fn get_default_dialog_content() -> String {
|
||||
"' Welcome Dialog\n\
|
||||
' Created with Dialog Designer\n\
|
||||
|
|
|
|||
845
src/directory/auth_routes.rs
Normal file
845
src/directory/auth_routes.rs
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Json},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use log::{error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::shared::state::AppState;
|
||||
|
||||
const BOOTSTRAP_SECRET_ENV: &str = "GB_BOOTSTRAP_SECRET";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub remember: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub success: bool,
|
||||
pub user_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: Option<i64>,
|
||||
pub requires_2fa: bool,
|
||||
pub session_token: Option<String>,
|
||||
pub redirect: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CurrentUserResponse {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub organization_id: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LogoutResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TwoFactorRequest {
|
||||
pub session_token: String,
|
||||
pub code: String,
|
||||
pub trust_device: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshTokenRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BootstrapAdminRequest {
|
||||
pub bootstrap_secret: String,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub organization_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BootstrapResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub user_id: Option<String>,
|
||||
pub organization_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn configure() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/api/auth/login", post(login))
|
||||
.route("/api/auth/logout", post(logout))
|
||||
.route("/api/auth/me", get(get_current_user))
|
||||
.route("/api/auth/refresh", post(refresh_token))
|
||||
.route("/api/auth/2fa/verify", post(verify_2fa))
|
||||
.route("/api/auth/2fa/resend", post(resend_2fa))
|
||||
.route("/api/auth/bootstrap", post(bootstrap_admin))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
info!("Login attempt for: {}", req.email);
|
||||
|
||||
let client = {
|
||||
let auth_service = state.auth_service.lock().await;
|
||||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
error!("Failed to create HTTP client: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let pat_path = std::path::Path::new("./botserver-stack/conf/directory/admin-pat.txt");
|
||||
let admin_token = std::fs::read_to_string(pat_path)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if admin_token.is_empty() {
|
||||
error!("Admin PAT token not found");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Authentication service not configured".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let search_url = format!("{}/v2/users", client.api_url());
|
||||
let search_body = serde_json::json!({
|
||||
"queries": [{
|
||||
"emailQuery": {
|
||||
"emailAddress": req.email,
|
||||
"method": "TEXT_QUERY_METHOD_EQUALS"
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let user_response = http_client
|
||||
.post(&search_url)
|
||||
.bearer_auth(&admin_token)
|
||||
.json(&search_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to search user: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Authentication service error".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !user_response.status().is_success() {
|
||||
let error_text = user_response.text().await.unwrap_or_default();
|
||||
error!("User search failed: {}", error_text);
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid email or password".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let user_data: serde_json::Value = user_response.json().await.map_err(|e| {
|
||||
error!("Failed to parse user response: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Authentication service error".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = user_data
|
||||
.get("result")
|
||||
.and_then(|r| r.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|u| u.get("userId"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let user_id = match user_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
error!("User not found: {}", req.email);
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid email or password".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let session_url = format!("{}/v2/sessions", client.api_url());
|
||||
let session_body = serde_json::json!({
|
||||
"checks": {
|
||||
"user": {
|
||||
"userId": user_id
|
||||
},
|
||||
"password": {
|
||||
"password": req.password
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let session_response = http_client
|
||||
.post(&session_url)
|
||||
.bearer_auth(&admin_token)
|
||||
.json(&session_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to create session: {}", e);
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Authentication failed".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !session_response.status().is_success() {
|
||||
let status = session_response.status();
|
||||
let error_text = session_response.text().await.unwrap_or_default();
|
||||
error!("Session creation failed: {} - {}", status, error_text);
|
||||
|
||||
if error_text.contains("password") || error_text.contains("invalid") {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid email or password".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Authentication failed".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let session_data: serde_json::Value = session_response.json().await.map_err(|e| {
|
||||
error!("Failed to parse session response: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid response from authentication server".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let session_id = session_data
|
||||
.get("sessionId")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let session_token = session_data
|
||||
.get("sessionToken")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(String::from);
|
||||
|
||||
info!("Login successful for: {} (user_id: {})", req.email, user_id);
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
user_id: Some(user_id),
|
||||
session_id: session_id.clone(),
|
||||
access_token: session_id,
|
||||
refresh_token: None,
|
||||
expires_in: Some(3600),
|
||||
requires_2fa: false,
|
||||
session_token,
|
||||
redirect: Some("/".to_string()),
|
||||
message: Some("Login successful".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<Json<LogoutResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|auth| auth.strip_prefix("Bearer "))
|
||||
.map(String::from);
|
||||
|
||||
if let Some(ref _token) = token {
|
||||
info!("User logged out");
|
||||
}
|
||||
|
||||
Ok(Json(LogoutResponse {
|
||||
success: true,
|
||||
message: "Logged out successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_current_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<Json<CurrentUserResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let session_token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|auth| auth.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Missing authorization token".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = {
|
||||
let auth_service = state.auth_service.lock().await;
|
||||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
let pat_path = std::path::Path::new("./botserver-stack/conf/directory/admin-pat.txt");
|
||||
let admin_token = std::fs::read_to_string(pat_path)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if admin_token.is_empty() {
|
||||
error!("Admin PAT token not found for user lookup");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Authentication service not configured".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
error!("Failed to create HTTP client: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let session_url = format!("{}/v2/sessions/{}", client.api_url(), session_token);
|
||||
let session_response = http_client
|
||||
.get(&session_url)
|
||||
.bearer_auth(&admin_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to get session: {}", e);
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Session validation failed".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !session_response.status().is_success() {
|
||||
let error_text = session_response.text().await.unwrap_or_default();
|
||||
error!("Session lookup failed: {}", error_text);
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid or expired session".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let session_data: serde_json::Value = session_response.json().await.map_err(|e| {
|
||||
error!("Failed to parse session response: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to parse session data".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = session_data
|
||||
.get("session")
|
||||
.and_then(|s| s.get("factors"))
|
||||
.and_then(|f| f.get("user"))
|
||||
.and_then(|u| u.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if user_id.is_empty() {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid session - no user found".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let user_url = format!("{}/v2/users/{}", client.api_url(), user_id);
|
||||
let user_response = http_client
|
||||
.get(&user_url)
|
||||
.bearer_auth(&admin_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to get user: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to fetch user data".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !user_response.status().is_success() {
|
||||
let error_text = user_response.text().await.unwrap_or_default();
|
||||
error!("User lookup failed: {}", error_text);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to fetch user data".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let user_data: serde_json::Value = user_response.json().await.map_err(|e| {
|
||||
error!("Failed to parse user response: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to parse user data".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user = user_data.get("user").unwrap_or(&user_data);
|
||||
let human = user.get("human");
|
||||
|
||||
let username = user
|
||||
.get("userName")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let email = human
|
||||
.and_then(|h| h.get("email"))
|
||||
.and_then(|e| e.get("email"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let first_name = human
|
||||
.and_then(|h| h.get("profile"))
|
||||
.and_then(|p| p.get("givenName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let last_name = human
|
||||
.and_then(|h| h.get("profile"))
|
||||
.and_then(|p| p.get("familyName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let display_name = human
|
||||
.and_then(|h| h.get("profile"))
|
||||
.and_then(|p| p.get("displayName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let organization_id = user
|
||||
.get("details")
|
||||
.and_then(|d| d.get("resourceOwner"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
info!("User profile loaded for: {} ({})", username, user_id);
|
||||
|
||||
Ok(Json(CurrentUserResponse {
|
||||
id: user_id,
|
||||
username,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
roles: vec!["admin".to_string()],
|
||||
organization_id,
|
||||
avatar_url: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn refresh_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<RefreshTokenRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let client = {
|
||||
let auth_service = state.auth_service.lock().await;
|
||||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
let token_url = format!("{}/oauth/v2/token", client.api_url());
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
error!("Failed to create HTTP client: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let params = [
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", &req.refresh_token),
|
||||
("scope", "openid profile email offline_access"),
|
||||
];
|
||||
|
||||
let response = http_client
|
||||
.post(&token_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to refresh token: {}", e);
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Token refresh failed".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid or expired refresh token".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let token_data: serde_json::Value = response.json().await.map_err(|e| {
|
||||
error!("Failed to parse token response: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid response from authentication server".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let access_token = token_data
|
||||
.get("access_token")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let refresh_token = token_data
|
||||
.get("refresh_token")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let expires_in = token_data.get("expires_in").and_then(|t| t.as_i64());
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
user_id: None,
|
||||
session_id: None,
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
requires_2fa: false,
|
||||
session_token: None,
|
||||
redirect: None,
|
||||
message: Some("Token refreshed successfully".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn verify_2fa(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Json(req): Json<TwoFactorRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
info!(
|
||||
"2FA verification attempt for session: {}",
|
||||
req.session_token
|
||||
);
|
||||
|
||||
Err((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(ErrorResponse {
|
||||
error: "2FA verification not yet implemented".to_string(),
|
||||
details: Some("This feature will be available in a future update".to_string()),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn resend_2fa(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Json(_req): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(ErrorResponse {
|
||||
error: "2FA resend not yet implemented".to_string(),
|
||||
details: Some("This feature will be available in a future update".to_string()),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn bootstrap_admin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<BootstrapAdminRequest>,
|
||||
) -> Result<Json<BootstrapResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
info!("Bootstrap admin request received");
|
||||
|
||||
let expected_secret = std::env::var(BOOTSTRAP_SECRET_ENV).unwrap_or_default();
|
||||
|
||||
if expected_secret.is_empty() {
|
||||
warn!("Bootstrap endpoint called but GB_BOOTSTRAP_SECRET not set");
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ErrorResponse {
|
||||
error: "Bootstrap not enabled".to_string(),
|
||||
details: Some("Set GB_BOOTSTRAP_SECRET environment variable to enable bootstrap".to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if req.bootstrap_secret != expected_secret {
|
||||
warn!("Bootstrap attempt with invalid secret");
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid bootstrap secret".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let client = {
|
||||
let auth_service = state.auth_service.lock().await;
|
||||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
let existing_users = client.list_users(1, 0).await.unwrap_or_default();
|
||||
if !existing_users.is_empty() {
|
||||
let has_admin = existing_users.iter().any(|u| {
|
||||
u.get("roles")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|roles| {
|
||||
roles.iter().any(|r| {
|
||||
r.as_str()
|
||||
.map(|s| s.to_lowercase().contains("admin"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if has_admin {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(ErrorResponse {
|
||||
error: "Admin user already exists".to_string(),
|
||||
details: Some("Bootstrap can only be used for initial setup".to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let user_id = match client
|
||||
.create_user(&req.email, &req.first_name, &req.last_name, Some(&req.username))
|
||||
.await
|
||||
{
|
||||
Ok(id) => {
|
||||
info!("Bootstrap admin user created: {}", id);
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create bootstrap admin: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to create admin user".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = set_user_password(&client, &user_id, &req.password).await {
|
||||
error!("Failed to set admin password: {}", e);
|
||||
}
|
||||
|
||||
let org_name = req.organization_name.unwrap_or_else(|| "Default Organization".to_string());
|
||||
let org_id = match create_organization(&client, &org_name).await {
|
||||
Ok(id) => {
|
||||
info!("Bootstrap organization created: {}", id);
|
||||
Some(id)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create organization (may already exist): {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref oid) = org_id {
|
||||
let admin_roles = vec![
|
||||
"admin".to_string(),
|
||||
"org_owner".to_string(),
|
||||
"user_manager".to_string(),
|
||||
];
|
||||
if let Err(e) = client.add_org_member(oid, &user_id, admin_roles).await {
|
||||
error!("Failed to add admin to organization: {}", e);
|
||||
} else {
|
||||
info!("Admin user added to organization with admin roles");
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Bootstrap complete: admin user {} created successfully",
|
||||
req.username
|
||||
);
|
||||
|
||||
Ok(Json(BootstrapResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Admin user '{}' created successfully. You can now login with your credentials.",
|
||||
req.username
|
||||
),
|
||||
user_id: Some(user_id),
|
||||
organization_id: org_id,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn set_user_password(
|
||||
client: &crate::directory::client::ZitadelClient,
|
||||
user_id: &str,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
let url = format!("{}/v2/users/{}/password", client.api_url(), user_id);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"newPassword": {
|
||||
"password": password,
|
||||
"changeRequired": false
|
||||
}
|
||||
});
|
||||
|
||||
let response = client
|
||||
.http_post(url)
|
||||
.await
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
Err(format!("Failed to set password: {}", error_text))
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_organization(
|
||||
client: &crate::directory::client::ZitadelClient,
|
||||
name: &str,
|
||||
) -> Result<String, String> {
|
||||
let url = format!("{}/v2/organizations", client.api_url());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"name": name
|
||||
});
|
||||
|
||||
let response = client
|
||||
.http_post(url)
|
||||
.await
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let data: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
let org_id = data
|
||||
.get("organizationId")
|
||||
.or_else(|| data.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "No organization ID in response".to_string())?
|
||||
.to_string();
|
||||
Ok(org_id)
|
||||
} else {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
Err(format!("Failed to create organization: {}", error_text))
|
||||
}
|
||||
}
|
||||
356
src/directory/bootstrap.rs
Normal file
356
src/directory/bootstrap.rs
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
use anyhow::Result;
|
||||
use log::{error, info, warn};
|
||||
use rand::Rng;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use super::client::ZitadelClient;
|
||||
|
||||
const ADMIN_USERNAME: &str = "admin";
|
||||
const DEFAULT_ORG_NAME: &str = "General Bots";
|
||||
|
||||
pub struct BootstrapResult {
|
||||
pub user_id: String,
|
||||
pub organization_id: Option<String>,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub initial_password: String,
|
||||
pub setup_url: String,
|
||||
}
|
||||
|
||||
pub async fn check_and_bootstrap_admin(client: &ZitadelClient) -> Result<Option<BootstrapResult>> {
|
||||
info!("Checking if bootstrap is needed...");
|
||||
|
||||
match client.list_users(10, 0).await {
|
||||
Ok(users) => {
|
||||
if !users.is_empty() {
|
||||
let has_admin = users.iter().any(|u| {
|
||||
let username = u
|
||||
.get("userName")
|
||||
.or_else(|| u.get("username"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let has_admin_role = u
|
||||
.get("roles")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|roles| {
|
||||
roles.iter().any(|r| {
|
||||
r.as_str()
|
||||
.map(|s| s.to_lowercase().contains("admin"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
username == ADMIN_USERNAME || has_admin_role
|
||||
});
|
||||
|
||||
if has_admin {
|
||||
info!("Admin user already exists, skipping bootstrap");
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Could not check existing users (may be first run): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
info!("No admin user found, bootstrapping initial admin account...");
|
||||
|
||||
let result = create_bootstrap_admin(client).await?;
|
||||
|
||||
print_bootstrap_credentials(&result);
|
||||
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
fn generate_secure_password() -> String {
|
||||
let mut rng = rand::rng();
|
||||
|
||||
let lowercase: Vec<char> = (b'a'..=b'z').map(|c| c as char).collect();
|
||||
let uppercase: Vec<char> = (b'A'..=b'Z').map(|c| c as char).collect();
|
||||
let digits: Vec<char> = (b'0'..=b'9').map(|c| c as char).collect();
|
||||
let special: Vec<char> = "!@#$%&*".chars().collect();
|
||||
|
||||
let mut password = Vec::with_capacity(16);
|
||||
|
||||
password.push(lowercase[rng.random_range(0..lowercase.len())]);
|
||||
password.push(uppercase[rng.random_range(0..uppercase.len())]);
|
||||
password.push(digits[rng.random_range(0..digits.len())]);
|
||||
password.push(special[rng.random_range(0..special.len())]);
|
||||
|
||||
let all_chars: Vec<char> = lowercase
|
||||
.iter()
|
||||
.chain(uppercase.iter())
|
||||
.chain(digits.iter())
|
||||
.chain(special.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
for _ in 0..12 {
|
||||
password.push(all_chars[rng.random_range(0..all_chars.len())]);
|
||||
}
|
||||
|
||||
for i in (1..password.len()).rev() {
|
||||
let j = rng.random_range(0..=i);
|
||||
password.swap(i, j);
|
||||
}
|
||||
|
||||
password.into_iter().collect()
|
||||
}
|
||||
|
||||
async fn create_bootstrap_admin(client: &ZitadelClient) -> Result<BootstrapResult> {
|
||||
let email = format!("{}@localhost", ADMIN_USERNAME);
|
||||
|
||||
let user_id = client
|
||||
.create_user(&email, "System", "Administrator", Some(ADMIN_USERNAME))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create admin user: {}", e))?;
|
||||
|
||||
info!("Created admin user with ID: {}", user_id);
|
||||
|
||||
let initial_password = generate_secure_password();
|
||||
|
||||
if let Err(e) = client.set_user_password(&user_id, &initial_password, true).await {
|
||||
warn!("Failed to set initial password via API: {}. User may need to use password reset flow.", e);
|
||||
} else {
|
||||
info!("Initial password set for admin user");
|
||||
}
|
||||
|
||||
let org_id = match create_default_organization(client).await {
|
||||
Ok(id) => {
|
||||
info!("Created default organization with ID: {}", id);
|
||||
|
||||
let admin_roles = vec![
|
||||
"admin".to_string(),
|
||||
"org_owner".to_string(),
|
||||
"user_manager".to_string(),
|
||||
];
|
||||
if let Err(e) = client.add_org_member(&id, &user_id, admin_roles).await {
|
||||
warn!("Failed to add admin to organization: {}", e);
|
||||
}
|
||||
|
||||
Some(id)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create default organization: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let base_url = client.api_url();
|
||||
let setup_url = format!("{}/ui/login", base_url);
|
||||
|
||||
let result = BootstrapResult {
|
||||
user_id: user_id.clone(),
|
||||
organization_id: org_id,
|
||||
username: ADMIN_USERNAME.to_string(),
|
||||
email: email.clone(),
|
||||
initial_password: initial_password.clone(),
|
||||
setup_url: setup_url.clone(),
|
||||
};
|
||||
|
||||
save_setup_credentials(&result);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn create_default_organization(client: &ZitadelClient) -> Result<String> {
|
||||
let url = format!("{}/v2/organizations", client.api_url());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"name": DEFAULT_ORG_NAME
|
||||
});
|
||||
|
||||
let response = client
|
||||
.http_post(url)
|
||||
.await
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create organization: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let data: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))?;
|
||||
|
||||
let org_id = data
|
||||
.get("organizationId")
|
||||
.or_else(|| data.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No organization ID in response"))?
|
||||
.to_string();
|
||||
|
||||
Ok(org_id)
|
||||
} else {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to create organization: {}",
|
||||
error_text
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn save_setup_credentials(result: &BootstrapResult) {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
let creds_path = format!("{}/.gb-setup-credentials", home);
|
||||
|
||||
let content = format!(
|
||||
r#"# General Bots Initial Setup Credentials
|
||||
# Created: {}
|
||||
# DELETE THIS FILE AFTER FIRST LOGIN
|
||||
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ ADMIN LOGIN CREDENTIALS (OTP) ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ Username: {:<46}║
|
||||
║ Password: {:<46}║
|
||||
║ Email: {:<46}║
|
||||
║ ║
|
||||
║ Login URL: {:<45}║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
|
||||
IMPORTANT:
|
||||
- This is a one-time password (OTP)
|
||||
- You will be required to change it on first login
|
||||
- Delete this file after you have logged in successfully
|
||||
|
||||
Alternative access via Zitadel console:
|
||||
1. Go to: {}/ui/console
|
||||
2. Login with admin PAT from: ./botserver-stack/conf/directory/admin-pat.txt
|
||||
3. Find user '{}' and manage settings
|
||||
"#,
|
||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
|
||||
result.username,
|
||||
result.initial_password,
|
||||
result.email,
|
||||
result.setup_url,
|
||||
result.setup_url.split("/ui/").next().unwrap_or("http://localhost:8300"),
|
||||
result.username
|
||||
);
|
||||
|
||||
match fs::write(&creds_path, &content) {
|
||||
Ok(_) => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Err(e) = fs::set_permissions(&creds_path, fs::Permissions::from_mode(0o600)) {
|
||||
warn!("Failed to set file permissions: {}", e);
|
||||
}
|
||||
}
|
||||
info!("Setup credentials saved to: {}", creds_path);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to save setup credentials: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_bootstrap_credentials(result: &BootstrapResult) {
|
||||
let separator = "═".repeat(60);
|
||||
|
||||
println!();
|
||||
println!("╔{}╗", separator);
|
||||
println!("║{:^60}║", "");
|
||||
println!("║{:^60}║", "🤖 GENERAL BOTS - INITIAL SETUP");
|
||||
println!("║{:^60}║", "");
|
||||
println!("╠{}╣", separator);
|
||||
println!("║{:^60}║", "");
|
||||
println!("║ {:56}║", "Administrator account created!");
|
||||
println!("║{:^60}║", "");
|
||||
println!("╠{}╣", separator);
|
||||
println!("║{:^60}║", "");
|
||||
println!("║{:^60}║", "🔐 ONE-TIME PASSWORD (OTP) FOR LOGIN:");
|
||||
println!("║{:^60}║", "");
|
||||
println!("║ {:<58}║", format!("Username: {}", result.username));
|
||||
println!("║ {:<58}║", format!("Password: {}", result.initial_password));
|
||||
println!("║ {:<58}║", format!("Email: {}", result.email));
|
||||
println!("║{:^60}║", "");
|
||||
|
||||
if let Some(ref org_id) = result.organization_id {
|
||||
println!(
|
||||
"║ {:<58}║",
|
||||
format!("Organization: {} ({})", DEFAULT_ORG_NAME, &org_id[..8.min(org_id.len())])
|
||||
);
|
||||
println!("║{:^60}║", "");
|
||||
}
|
||||
|
||||
println!("╠{}╣", separator);
|
||||
println!("║{:^60}║", "");
|
||||
println!("║ {:56}║", "🌐 LOGIN URL:");
|
||||
println!("║{:^60}║", "");
|
||||
|
||||
let url_display = if result.setup_url.len() > 54 {
|
||||
format!("{}...", &result.setup_url[..51])
|
||||
} else {
|
||||
result.setup_url.clone()
|
||||
};
|
||||
println!("║ {:56}║", url_display);
|
||||
println!("║{:^60}║", "");
|
||||
println!("╠{}╣", separator);
|
||||
println!("║{:^60}║", "");
|
||||
println!("║ ⚠️ {:<53}║", "IMPORTANT - SAVE THESE CREDENTIALS!");
|
||||
println!("║{:^60}║", "");
|
||||
println!("║ {:<56}║", "• This password will NOT be shown again");
|
||||
println!("║ {:<56}║", "• You must change it on first login");
|
||||
println!("║ {:<56}║", "• Credentials also saved to: ~/.gb-setup-credentials");
|
||||
println!("║{:^60}║", "");
|
||||
println!("╚{}╝", separator);
|
||||
println!();
|
||||
|
||||
info!(
|
||||
"Bootstrap complete: admin user '{}' created with OTP password",
|
||||
result.username
|
||||
);
|
||||
}
|
||||
|
||||
pub fn print_existing_admin_notice() {
|
||||
println!();
|
||||
println!("ℹ️ Admin user already exists. Skipping bootstrap.");
|
||||
println!(" If you forgot your password, use Zitadel console to reset it.");
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_secure_password() {
|
||||
let password = generate_secure_password();
|
||||
|
||||
assert!(password.len() >= 14);
|
||||
|
||||
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
|
||||
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
|
||||
let has_digit = password.chars().any(|c| c.is_ascii_digit());
|
||||
let has_special = password.chars().any(|c| "!@#$%&*".contains(c));
|
||||
|
||||
assert!(has_lower, "Password should contain lowercase");
|
||||
assert!(has_upper, "Password should contain uppercase");
|
||||
assert!(has_digit, "Password should contain digits");
|
||||
assert!(has_special, "Password should contain special chars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_uniqueness() {
|
||||
let passwords: Vec<String> = (0..10).map(|_| generate_secure_password()).collect();
|
||||
|
||||
for i in 0..passwords.len() {
|
||||
for j in (i + 1)..passwords.len() {
|
||||
assert_ne!(
|
||||
passwords[i], passwords[j],
|
||||
"Generated passwords should be unique"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ pub struct ZitadelClient {
|
|||
config: ZitadelConfig,
|
||||
http_client: reqwest::Client,
|
||||
access_token: Arc<RwLock<Option<String>>>,
|
||||
pat_token: Option<String>,
|
||||
}
|
||||
|
||||
impl ZitadelClient {
|
||||
|
|
@ -33,13 +34,40 @@ impl ZitadelClient {
|
|||
config,
|
||||
http_client,
|
||||
access_token: Arc::new(RwLock::new(None)),
|
||||
pat_token: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_pat_token(config: ZitadelConfig, pat_token: String) -> Result<Self> {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
http_client,
|
||||
access_token: Arc::new(RwLock::new(None)),
|
||||
pat_token: Some(pat_token),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_pat_token(&mut self, token: String) {
|
||||
self.pat_token = Some(token);
|
||||
}
|
||||
|
||||
pub fn api_url(&self) -> &str {
|
||||
&self.config.api_url
|
||||
}
|
||||
|
||||
pub fn client_id(&self) -> &str {
|
||||
&self.config.client_id
|
||||
}
|
||||
|
||||
pub fn client_secret(&self) -> &str {
|
||||
&self.config.client_secret
|
||||
}
|
||||
|
||||
pub async fn http_get(&self, url: String) -> reqwest::RequestBuilder {
|
||||
let token = self.get_access_token().await.unwrap_or_default();
|
||||
self.http_client.get(url).bearer_auth(token)
|
||||
|
|
@ -60,7 +88,16 @@ impl ZitadelClient {
|
|||
self.http_client.patch(url).bearer_auth(token)
|
||||
}
|
||||
|
||||
pub async fn http_delete(&self, url: String) -> reqwest::RequestBuilder {
|
||||
let token = self.get_access_token().await.unwrap_or_default();
|
||||
self.http_client.delete(url).bearer_auth(token)
|
||||
}
|
||||
|
||||
pub async fn get_access_token(&self) -> Result<String> {
|
||||
if let Some(ref pat) = self.pat_token {
|
||||
return Ok(pat.clone());
|
||||
}
|
||||
|
||||
{
|
||||
let token = self.access_token.read().await;
|
||||
if let Some(t) = token.as_ref() {
|
||||
|
|
@ -69,6 +106,7 @@ impl ZitadelClient {
|
|||
}
|
||||
|
||||
let token_url = format!("{}/oauth/v2/token", self.config.api_url);
|
||||
log::info!("Requesting access token from: {}", token_url);
|
||||
|
||||
let params = [
|
||||
("grant_type", "client_credentials"),
|
||||
|
|
@ -123,7 +161,7 @@ impl ZitadelClient {
|
|||
},
|
||||
"email": {
|
||||
"email": email,
|
||||
"isVerified": false
|
||||
"isVerified": true
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -467,4 +505,32 @@ impl ZitadelClient {
|
|||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn set_user_password(&self, user_id: &str, password: &str, change_required: bool) -> Result<()> {
|
||||
let token = self.get_access_token().await?;
|
||||
let url = format!("{}/v2/users/{}/password", self.config.api_url, user_id);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"newPassword": {
|
||||
"password": password,
|
||||
"changeRequired": change_required
|
||||
}
|
||||
});
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to set password: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("Failed to set password: {}", error_text));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod auth_routes;
|
||||
pub mod bootstrap;
|
||||
pub mod client;
|
||||
pub mod groups;
|
||||
pub mod router;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
use axum::{
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
|
|
@ -10,32 +9,43 @@ use crate::shared::state::AppState;
|
|||
use super::groups;
|
||||
use super::users;
|
||||
|
||||
|
||||
|
||||
pub fn configure() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
|
||||
|
||||
|
||||
.route("/users/create", post(users::create_user))
|
||||
.route("/users/{user_id}/update", put(users::update_user))
|
||||
.route("/users/{user_id}/delete", delete(users::delete_user))
|
||||
.route("/users/:user_id/update", put(users::update_user))
|
||||
.route("/users/:user_id/delete", delete(users::delete_user))
|
||||
.route("/users/list", get(users::list_users))
|
||||
.route("/users/search", get(users::list_users))
|
||||
.route("/users/{user_id}/profile", get(users::get_user_profile))
|
||||
.route("/users/{user_id}/profile/update", put(users::update_user))
|
||||
.route("/users/{user_id}/settings", get(users::get_user_profile))
|
||||
.route("/users/{user_id}/permissions", get(users::get_user_profile))
|
||||
.route("/users/{user_id}/roles", get(users::get_user_profile))
|
||||
.route("/users/{user_id}/status", get(users::get_user_profile))
|
||||
.route("/users/{user_id}/presence", get(users::get_user_profile))
|
||||
.route("/users/{user_id}/activity", get(users::get_user_profile))
|
||||
.route("/users/:user_id/profile", get(users::get_user_profile))
|
||||
.route("/users/:user_id/profile/update", put(users::update_user))
|
||||
.route("/users/:user_id/settings", get(users::get_user_profile))
|
||||
.route("/users/:user_id/permissions", get(users::get_user_profile))
|
||||
.route("/users/:user_id/roles", get(users::get_user_profile))
|
||||
.route("/users/:user_id/status", get(users::get_user_profile))
|
||||
.route("/users/:user_id/presence", get(users::get_user_profile))
|
||||
.route("/users/:user_id/activity", get(users::get_user_profile))
|
||||
.route(
|
||||
"/users/{user_id}/security/2fa/enable",
|
||||
"/users/:user_id/organization",
|
||||
post(users::assign_organization),
|
||||
)
|
||||
.route(
|
||||
"/users/:user_id/organization/:org_id",
|
||||
delete(users::remove_from_organization),
|
||||
)
|
||||
.route(
|
||||
"/users/:user_id/organization/:org_id/roles",
|
||||
put(users::update_user_roles),
|
||||
)
|
||||
.route(
|
||||
"/users/:user_id/memberships",
|
||||
get(users::get_user_memberships),
|
||||
)
|
||||
.route(
|
||||
"/users/:user_id/security/2fa/enable",
|
||||
post(users::get_user_profile),
|
||||
)
|
||||
.route(
|
||||
"/users/{user_id}/security/2fa/disable",
|
||||
"/users/:user_id/security/2fa/disable",
|
||||
post(users::get_user_profile),
|
||||
)
|
||||
.route(
|
||||
|
|
@ -47,36 +57,33 @@ pub fn configure() -> Router<Arc<AppState>> {
|
|||
get(users::get_user_profile),
|
||||
)
|
||||
.route(
|
||||
"/users/{user_id}/notifications/preferences/update",
|
||||
"/users/:user_id/notifications/preferences/update",
|
||||
get(users::get_user_profile),
|
||||
)
|
||||
|
||||
|
||||
|
||||
.route("/groups/create", post(groups::create_group))
|
||||
.route("/groups/{group_id}/update", put(groups::update_group))
|
||||
.route("/groups/{group_id}/delete", delete(groups::delete_group))
|
||||
.route("/groups/:group_id/update", put(groups::update_group))
|
||||
.route("/groups/:group_id/delete", delete(groups::delete_group))
|
||||
.route("/groups/list", get(groups::list_groups))
|
||||
.route("/groups/search", get(groups::list_groups))
|
||||
.route("/groups/{group_id}/members", get(groups::get_group_members))
|
||||
.route("/groups/:group_id/members", get(groups::get_group_members))
|
||||
.route(
|
||||
"/groups/{group_id}/members/add",
|
||||
"/groups/:group_id/members/add",
|
||||
post(groups::add_group_member),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/members/roles",
|
||||
"/groups/:group_id/members/roles",
|
||||
post(groups::remove_group_member),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/permissions",
|
||||
"/groups/:group_id/permissions",
|
||||
get(groups::get_group_members),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/settings",
|
||||
"/groups/:group_id/settings",
|
||||
get(groups::get_group_members),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/analytics",
|
||||
"/groups/:group_id/analytics",
|
||||
get(groups::get_group_members),
|
||||
)
|
||||
.route(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
|
|
@ -9,20 +8,19 @@ use log::{error, info};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
use crate::shared::state::AppState;
|
||||
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub password: Option<String>,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub display_name: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub organization_id: Option<String>,
|
||||
pub roles: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -33,6 +31,8 @@ pub struct UpdateUserRequest {
|
|||
pub display_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub organization_id: Option<String>,
|
||||
pub roles: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -40,6 +40,7 @@ pub struct UserQuery {
|
|||
pub page: Option<u32>,
|
||||
pub per_page: Option<u32>,
|
||||
pub search: Option<String>,
|
||||
pub organization_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -51,6 +52,8 @@ pub struct UserResponse {
|
|||
pub last_name: String,
|
||||
pub display_name: Option<String>,
|
||||
pub state: String,
|
||||
pub organization_id: Option<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
|
@ -76,8 +79,16 @@ pub struct ErrorResponse {
|
|||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssignOrganizationRequest {
|
||||
pub organization_id: String,
|
||||
pub roles: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateRolesRequest {
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
|
@ -85,44 +96,52 @@ pub async fn create_user(
|
|||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
info!("Creating user: {} ({})", req.username, req.email);
|
||||
|
||||
|
||||
let client = {
|
||||
let auth_service = state.auth_service.lock().await;
|
||||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
|
||||
match client
|
||||
.create_user(
|
||||
&req.email,
|
||||
&req.first_name,
|
||||
&req.last_name,
|
||||
Some(&req.username),
|
||||
)
|
||||
let user_id = match client
|
||||
.create_user(&req.email, &req.first_name, &req.last_name, Some(&req.username))
|
||||
.await
|
||||
{
|
||||
Ok(user_id) => {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!("Failed to create user in Zitadel: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to create user".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref org_id) = req.organization_id {
|
||||
let roles = req.roles.clone().unwrap_or_else(|| vec!["user".to_string()]);
|
||||
|
||||
if let Err(e) = client.add_org_member(org_id, &user_id, roles.clone()).await {
|
||||
error!(
|
||||
"Failed to add user {} to organization {}: {}",
|
||||
user_id, org_id, e
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"User {} added to organization {} with roles {:?}",
|
||||
user_id, org_id, roles
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
info!("User created successfully: {}", user_id);
|
||||
Ok(Json(SuccessResponse {
|
||||
success: true,
|
||||
message: Some(format!("User {} created successfully", req.username)),
|
||||
user_id: Some(user_id),
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create user: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to create user".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn update_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(user_id): Path<String>,
|
||||
|
|
@ -135,7 +154,6 @@ pub async fn update_user(
|
|||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
|
||||
let mut update_data = serde_json::Map::new();
|
||||
if let Some(username) = &req.username {
|
||||
update_data.insert("userName".to_string(), serde_json::json!(username));
|
||||
|
|
@ -156,7 +174,7 @@ pub async fn update_user(
|
|||
update_data.insert("phone".to_string(), serde_json::json!(phone));
|
||||
}
|
||||
|
||||
|
||||
if !update_data.is_empty() {
|
||||
match client
|
||||
.http_patch(format!("{}/users/{}", client.api_url(), user_id))
|
||||
.await
|
||||
|
|
@ -165,37 +183,41 @@ pub async fn update_user(
|
|||
.await
|
||||
{
|
||||
Ok(response) if response.status().is_success() => {
|
||||
info!("User {} updated successfully", user_id);
|
||||
info!("User {} profile updated successfully", user_id);
|
||||
}
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
error!("Failed to update user profile: {}", status);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update user profile: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref org_id) = req.organization_id {
|
||||
let roles = req.roles.clone().unwrap_or_else(|| vec!["user".to_string()]);
|
||||
|
||||
if let Err(e) = client.add_org_member(org_id, &user_id, roles.clone()).await {
|
||||
error!(
|
||||
"Failed to update user {} organization membership: {}",
|
||||
user_id, e
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"User {} organization membership updated to {} with roles {:?}",
|
||||
user_id, org_id, roles
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
success: true,
|
||||
message: Some(format!("User {} updated successfully", user_id)),
|
||||
user_id: Some(user_id),
|
||||
}))
|
||||
}
|
||||
Ok(_) => {
|
||||
error!("Failed to update user: unexpected response");
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to update user".to_string(),
|
||||
details: Some("Unexpected response from server".to_string()),
|
||||
}),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update user: {}", e);
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn delete_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(user_id): Path<String>,
|
||||
|
|
@ -207,23 +229,37 @@ pub async fn delete_user(
|
|||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
|
||||
match client.get_user(&user_id).await {
|
||||
Ok(_) => {
|
||||
|
||||
info!("User {} deleted/deactivated", user_id);
|
||||
match client
|
||||
.http_delete(format!("{}/v2/users/{}", client.api_url(), user_id))
|
||||
.await
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) if response.status().is_success() => {
|
||||
info!("User {} deleted successfully", user_id);
|
||||
Ok(Json(SuccessResponse {
|
||||
success: true,
|
||||
message: Some(format!("User {} deleted successfully", user_id)),
|
||||
user_id: Some(user_id),
|
||||
}))
|
||||
}
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
error!("Failed to delete user: {}", status);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to delete user".to_string(),
|
||||
details: Some(format!("Server returned {}", status)),
|
||||
}),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to delete user: {}", e);
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
error: "Failed to delete user".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
))
|
||||
|
|
@ -231,7 +267,6 @@ pub async fn delete_user(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn list_users(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<UserQuery>,
|
||||
|
|
@ -246,9 +281,12 @@ pub async fn list_users(
|
|||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
let users_result = if let Some(search_term) = params.search {
|
||||
let users_result = if let Some(ref org_id) = params.organization_id {
|
||||
info!("Filtering users by organization: {}", org_id);
|
||||
client.get_org_members(org_id).await
|
||||
} else if let Some(ref search_term) = params.search {
|
||||
info!("Searching users with term: {}", search_term);
|
||||
client.search_users(&search_term).await
|
||||
client.search_users(search_term).await
|
||||
} else {
|
||||
let offset = (page - 1) * per_page;
|
||||
client.list_users(per_page, offset).await
|
||||
|
|
@ -259,22 +297,63 @@ pub async fn list_users(
|
|||
let users: Vec<UserResponse> = users_json
|
||||
.into_iter()
|
||||
.filter_map(|u| {
|
||||
Some(UserResponse {
|
||||
id: u.get("userId")?.as_str()?.to_string(),
|
||||
username: u.get("userName")?.as_str()?.to_string(),
|
||||
email: u
|
||||
.get("preferredLoginName")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown@example.com")
|
||||
.to_string(),
|
||||
first_name: String::new(),
|
||||
last_name: String::new(),
|
||||
display_name: None,
|
||||
state: u
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
let id = u.get("userId").and_then(|v| v.as_str()).map(String::from)
|
||||
.or_else(|| u.get("user_id").and_then(|v| v.as_str()).map(String::from))?;
|
||||
|
||||
let username = u.get("userName").and_then(|v| v.as_str())
|
||||
.or_else(|| u.get("username").and_then(|v| v.as_str()))
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
.to_string();
|
||||
|
||||
let email = u.get("preferredLoginName").and_then(|v| v.as_str())
|
||||
.or_else(|| u.get("email").and_then(|v| v.as_str()))
|
||||
.unwrap_or("unknown@example.com")
|
||||
.to_string();
|
||||
|
||||
let first_name = u.get("profile")
|
||||
.and_then(|p| p.get("givenName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let last_name = u.get("profile")
|
||||
.and_then(|p| p.get("familyName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let display_name = u.get("profile")
|
||||
.and_then(|p| p.get("displayName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let state = u.get("state").and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let organization_id = u.get("orgId").and_then(|v| v.as_str())
|
||||
.or_else(|| u.get("organization_id").and_then(|v| v.as_str()))
|
||||
.map(String::from);
|
||||
|
||||
let roles = u.get("roles")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|r| r.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(UserResponse {
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
state,
|
||||
organization_id,
|
||||
roles,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
})
|
||||
|
|
@ -304,7 +383,6 @@ pub async fn list_users(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn get_user_profile(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(user_id): Path<String>,
|
||||
|
|
@ -318,35 +396,68 @@ pub async fn get_user_profile(
|
|||
|
||||
match client.get_user(&user_id).await {
|
||||
Ok(user_data) => {
|
||||
let user = UserResponse {
|
||||
id: user_data
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
let id = user_data.get("id").and_then(|v| v.as_str())
|
||||
.unwrap_or(&user_id)
|
||||
.to_string(),
|
||||
username: user_data
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.to_string();
|
||||
|
||||
let username = user_data.get("username").and_then(|v| v.as_str())
|
||||
.or_else(|| user_data.get("userName").and_then(|v| v.as_str()))
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
email: user_data
|
||||
.get("preferredLoginName")
|
||||
.and_then(|v| v.as_str())
|
||||
.to_string();
|
||||
|
||||
let email = user_data.get("preferredLoginName").and_then(|v| v.as_str())
|
||||
.or_else(|| user_data.get("email").and_then(|v| v.as_str()))
|
||||
.unwrap_or("unknown@example.com")
|
||||
.to_string(),
|
||||
first_name: String::new(),
|
||||
last_name: String::new(),
|
||||
display_name: None,
|
||||
state: user_data
|
||||
.get("state")
|
||||
.to_string();
|
||||
|
||||
let first_name = user_data.get("profile")
|
||||
.and_then(|p| p.get("givenName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let last_name = user_data.get("profile")
|
||||
.and_then(|p| p.get("familyName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let display_name = user_data.get("profile")
|
||||
.and_then(|p| p.get("displayName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let state = user_data.get("state").and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
.to_string();
|
||||
|
||||
let organization_id = user_data.get("orgId").and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let roles = user_data.get("roles")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|r| r.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let user = UserResponse {
|
||||
id,
|
||||
username: username.clone(),
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
state,
|
||||
organization_id,
|
||||
roles,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
info!("User profile retrieved: {}", user.username);
|
||||
info!("User profile retrieved: {}", username);
|
||||
Ok(Json(user))
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -361,3 +472,160 @@ pub async fn get_user_profile(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn assign_organization(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(user_id): Path<String>,
|
||||
Json(req): Json<AssignOrganizationRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
info!(
|
||||
"Assigning user {} to organization {}",
|
||||
user_id, req.organization_id
|
||||
);
|
||||
|
||||
let client = {
|
||||
let auth_service = state.auth_service.lock().await;
|
||||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
let roles = req.roles.unwrap_or_else(|| vec!["user".to_string()]);
|
||||
|
||||
match client
|
||||
.add_org_member(&req.organization_id, &user_id, roles.clone())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"User {} assigned to organization {} with roles {:?}",
|
||||
user_id, req.organization_id, roles
|
||||
);
|
||||
Ok(Json(SuccessResponse {
|
||||
success: true,
|
||||
message: Some(format!(
|
||||
"User assigned to organization {} with roles {:?}",
|
||||
req.organization_id, roles
|
||||
)),
|
||||
user_id: Some(user_id),
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to assign user to organization: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to assign user to organization".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_from_organization(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((user_id, org_id)): Path<(String, String)>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
info!("Removing user {} from organization {}", user_id, org_id);
|
||||
|
||||
let client = {
|
||||
let auth_service = state.auth_service.lock().await;
|
||||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
match client.remove_org_member(&org_id, &user_id).await {
|
||||
Ok(()) => {
|
||||
info!("User {} removed from organization {}", user_id, org_id);
|
||||
Ok(Json(SuccessResponse {
|
||||
success: true,
|
||||
message: Some(format!("User removed from organization {}", org_id)),
|
||||
user_id: Some(user_id),
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to remove user from organization: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to remove user from organization".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_memberships(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(user_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||||
info!("Getting memberships for user: {}", user_id);
|
||||
|
||||
let client = {
|
||||
let auth_service = state.auth_service.lock().await;
|
||||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
match client.get_user_memberships(&user_id, 0, 100).await {
|
||||
Ok(memberships) => {
|
||||
info!("Retrieved memberships for user {}", user_id);
|
||||
Ok(Json(memberships))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get user memberships: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to get user memberships".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_user_roles(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((user_id, org_id)): Path<(String, String)>,
|
||||
Json(req): Json<UpdateRolesRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
info!(
|
||||
"Updating roles for user {} in organization {}: {:?}",
|
||||
user_id, org_id, req.roles
|
||||
);
|
||||
|
||||
let client = {
|
||||
let auth_service = state.auth_service.lock().await;
|
||||
auth_service.client().clone()
|
||||
};
|
||||
|
||||
if let Err(e) = client.remove_org_member(&org_id, &user_id).await {
|
||||
error!("Failed to remove existing membership: {}", e);
|
||||
}
|
||||
|
||||
match client
|
||||
.add_org_member(&org_id, &user_id, req.roles.clone())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"User {} roles updated in organization {}: {:?}",
|
||||
user_id, org_id, req.roles
|
||||
);
|
||||
Ok(Json(SuccessResponse {
|
||||
success: true,
|
||||
message: Some(format!("User roles updated to {:?}", req.roles)),
|
||||
user_id: Some(user_id),
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update user roles: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to update user roles".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1479
src/docs/mod.rs
Normal file
1479
src/docs/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -86,7 +86,7 @@ impl DriveMonitor {
|
|||
}
|
||||
|
||||
pub async fn start_monitoring(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
trace!("[PROFILE] start_monitoring ENTER");
|
||||
trace!("start_monitoring ENTER");
|
||||
let start_mem = MemoryStats::current();
|
||||
trace!("[DRIVE_MONITOR] Starting DriveMonitor for bot {}, RSS={}",
|
||||
self.bot_id, MemoryStats::format_bytes(start_mem.rss_bytes));
|
||||
|
|
@ -99,7 +99,7 @@ impl DriveMonitor {
|
|||
self.is_processing
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
trace!("[PROFILE] start_monitoring: calling check_for_changes...");
|
||||
trace!("start_monitoring: calling check_for_changes...");
|
||||
info!("[DRIVE_MONITOR] Calling initial check_for_changes...");
|
||||
|
||||
match self.check_for_changes().await {
|
||||
|
|
@ -111,7 +111,7 @@ impl DriveMonitor {
|
|||
self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
trace!("[PROFILE] start_monitoring: check_for_changes returned");
|
||||
trace!("start_monitoring: check_for_changes returned");
|
||||
|
||||
let after_initial = MemoryStats::current();
|
||||
trace!("[DRIVE_MONITOR] After initial check, RSS={} (delta={})",
|
||||
|
|
@ -215,38 +215,38 @@ impl DriveMonitor {
|
|||
})
|
||||
}
|
||||
async fn check_for_changes(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
trace!("[PROFILE] check_for_changes ENTER");
|
||||
trace!("check_for_changes ENTER");
|
||||
let start_mem = MemoryStats::current();
|
||||
trace!("[DRIVE_MONITOR] check_for_changes START, RSS={}",
|
||||
MemoryStats::format_bytes(start_mem.rss_bytes));
|
||||
|
||||
let Some(client) = &self.state.drive else {
|
||||
trace!("[PROFILE] check_for_changes: no drive client, returning");
|
||||
trace!("check_for_changes: no drive client, returning");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
trace!("[PROFILE] check_for_changes: calling check_gbdialog_changes...");
|
||||
trace!("check_for_changes: calling check_gbdialog_changes...");
|
||||
trace!("[DRIVE_MONITOR] Checking gbdialog...");
|
||||
self.check_gbdialog_changes(client).await?;
|
||||
trace!("[PROFILE] check_for_changes: check_gbdialog_changes done");
|
||||
trace!("check_for_changes: check_gbdialog_changes done");
|
||||
let after_dialog = MemoryStats::current();
|
||||
trace!("[DRIVE_MONITOR] After gbdialog, RSS={} (delta={})",
|
||||
MemoryStats::format_bytes(after_dialog.rss_bytes),
|
||||
MemoryStats::format_bytes(after_dialog.rss_bytes.saturating_sub(start_mem.rss_bytes)));
|
||||
|
||||
trace!("[PROFILE] check_for_changes: calling check_gbot...");
|
||||
trace!("check_for_changes: calling check_gbot...");
|
||||
trace!("[DRIVE_MONITOR] Checking gbot...");
|
||||
self.check_gbot(client).await?;
|
||||
trace!("[PROFILE] check_for_changes: check_gbot done");
|
||||
trace!("check_for_changes: check_gbot done");
|
||||
let after_gbot = MemoryStats::current();
|
||||
trace!("[DRIVE_MONITOR] After gbot, RSS={} (delta={})",
|
||||
MemoryStats::format_bytes(after_gbot.rss_bytes),
|
||||
MemoryStats::format_bytes(after_gbot.rss_bytes.saturating_sub(after_dialog.rss_bytes)));
|
||||
|
||||
trace!("[PROFILE] check_for_changes: calling check_gbkb_changes...");
|
||||
trace!("check_for_changes: calling check_gbkb_changes...");
|
||||
trace!("[DRIVE_MONITOR] Checking gbkb...");
|
||||
self.check_gbkb_changes(client).await?;
|
||||
trace!("[PROFILE] check_for_changes: check_gbkb_changes done");
|
||||
trace!("check_for_changes: check_gbkb_changes done");
|
||||
let after_gbkb = MemoryStats::current();
|
||||
trace!("[DRIVE_MONITOR] After gbkb, RSS={} (delta={})",
|
||||
MemoryStats::format_bytes(after_gbkb.rss_bytes),
|
||||
|
|
@ -260,7 +260,7 @@ impl DriveMonitor {
|
|||
MemoryStats::format_bytes(total_delta));
|
||||
}
|
||||
|
||||
trace!("[PROFILE] check_for_changes EXIT");
|
||||
trace!("check_for_changes EXIT");
|
||||
Ok(())
|
||||
}
|
||||
async fn check_gbdialog_changes(
|
||||
|
|
@ -335,7 +335,7 @@ impl DriveMonitor {
|
|||
Ok(())
|
||||
}
|
||||
async fn check_gbot(&self, client: &Client) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
trace!("[PROFILE] check_gbot ENTER");
|
||||
trace!("check_gbot ENTER");
|
||||
let config_manager = ConfigManager::new(self.state.conn.clone());
|
||||
debug!("check_gbot: Checking bucket {} for config.csv changes", self.bucket_name);
|
||||
let mut continuation_token = None;
|
||||
|
|
@ -481,7 +481,7 @@ impl DriveMonitor {
|
|||
}
|
||||
continuation_token = list_objects.next_continuation_token;
|
||||
}
|
||||
trace!("[PROFILE] check_gbot EXIT");
|
||||
trace!("check_gbot EXIT");
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_theme_change(
|
||||
|
|
@ -616,7 +616,7 @@ impl DriveMonitor {
|
|||
&self,
|
||||
client: &Client,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
trace!("[PROFILE] check_gbkb_changes ENTER");
|
||||
trace!("check_gbkb_changes ENTER");
|
||||
let bot_name = self
|
||||
.bucket_name
|
||||
.strip_suffix(".gbai")
|
||||
|
|
@ -850,7 +850,7 @@ impl DriveMonitor {
|
|||
}
|
||||
}
|
||||
|
||||
trace!("[PROFILE] check_gbkb_changes EXIT");
|
||||
trace!("check_gbkb_changes EXIT");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use crate::{config::EmailConfig, core::urls::ApiUrls, shared::state::AppState};
|
|||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use axum::{
|
||||
|
|
@ -129,8 +129,8 @@ pub fn configure() -> Router<Arc<AppState>> {
|
|||
.route(ApiUrls::EMAIL_LIST_HTMX, get(list_emails_htmx))
|
||||
.route(ApiUrls::EMAIL_FOLDERS_HTMX, get(list_folders_htmx))
|
||||
.route(ApiUrls::EMAIL_COMPOSE_HTMX, get(compose_email_htmx))
|
||||
.route(&ApiUrls::EMAIL_CONTENT_HTMX.replace(":id", "{id}"), get(get_email_content_htmx))
|
||||
.route("/api/ui/email/{id}/delete", delete(delete_email_htmx))
|
||||
.route(ApiUrls::EMAIL_CONTENT_HTMX, get(get_email_content_htmx))
|
||||
.route("/api/ui/email/:id/delete", delete(delete_email_htmx))
|
||||
.route(ApiUrls::EMAIL_LABELS_HTMX, get(list_labels_htmx))
|
||||
.route(ApiUrls::EMAIL_TEMPLATES_HTMX, get(list_templates_htmx))
|
||||
.route(ApiUrls::EMAIL_SIGNATURES_HTMX, get(list_signatures_htmx))
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ use anyhow::Result;
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(feature = "vectordb"))]
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "vectordb")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "vectordb")]
|
||||
use qdrant_client::{
|
||||
qdrant::{Distance, PointStruct, VectorParams},
|
||||
|
|
@ -111,7 +112,19 @@ impl UserEmailVectorDB {
|
|||
|
||||
#[cfg(not(feature = "vectordb"))]
|
||||
pub async fn initialize(&mut self, _qdrant_url: &str) -> Result<()> {
|
||||
log::warn!("Vector DB feature not enabled, using fallback storage");
|
||||
log::warn!(
|
||||
"Vector DB feature not enabled for user={} bot={}, using fallback storage at {}",
|
||||
self.user_id,
|
||||
self.bot_id,
|
||||
self.db_path.display()
|
||||
);
|
||||
std::fs::create_dir_all(&self.db_path)?;
|
||||
let metadata_path = self.db_path.join(format!("{}.meta", self.collection_name));
|
||||
let metadata = format!(
|
||||
"{{\"user_id\":\"{}\",\"bot_id\":\"{}\",\"collection\":\"{}\"}}",
|
||||
self.user_id, self.bot_id, self.collection_name
|
||||
);
|
||||
std::fs::write(metadata_path, metadata)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ pub mod security;
|
|||
|
||||
pub mod analytics;
|
||||
pub mod designer;
|
||||
pub mod docs;
|
||||
pub mod paper;
|
||||
pub mod research;
|
||||
pub mod sheet;
|
||||
pub mod slides;
|
||||
pub mod sources;
|
||||
|
||||
pub use core::shared;
|
||||
|
|
@ -94,6 +97,9 @@ pub mod weba;
|
|||
#[cfg(feature = "whatsapp")]
|
||||
pub mod whatsapp;
|
||||
|
||||
#[cfg(feature = "telegram")]
|
||||
pub mod telegram;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ use tokio;
|
|||
pub async fn ensure_llama_servers_running(
|
||||
app_state: Arc<AppState>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
trace!("[PROFILE] ensure_llama_servers_running ENTER");
|
||||
trace!("ensure_llama_servers_running ENTER");
|
||||
let start_mem = MemoryStats::current();
|
||||
trace!("[LLM_LOCAL] ensure_llama_servers_running START, RSS={}",
|
||||
MemoryStats::format_bytes(start_mem.rss_bytes));
|
||||
log_jemalloc_stats();
|
||||
|
||||
if std::env::var("SKIP_LLM_SERVER").is_ok() {
|
||||
trace!("[PROFILE] SKIP_LLM_SERVER set, returning early");
|
||||
trace!("SKIP_LLM_SERVER set, returning early");
|
||||
info!("SKIP_LLM_SERVER set - skipping local LLM server startup (using mock/external LLM)");
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ pub async fn ensure_llama_servers_running(
|
|||
info!(" Embedding Model: {embedding_model}");
|
||||
info!(" LLM Server Path: {llm_server_path}");
|
||||
info!("Restarting any existing llama-server processes...");
|
||||
trace!("[PROFILE] About to pkill llama-server...");
|
||||
trace!("About to pkill llama-server...");
|
||||
let before_pkill = MemoryStats::current();
|
||||
trace!("[LLM_LOCAL] Before pkill, RSS={}", MemoryStats::format_bytes(before_pkill.rss_bytes));
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ pub async fn ensure_llama_servers_running(
|
|||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
info!("Existing llama-server processes terminated (if any)");
|
||||
}
|
||||
trace!("[PROFILE] pkill done");
|
||||
trace!("pkill done");
|
||||
|
||||
let after_pkill = MemoryStats::current();
|
||||
trace!("[LLM_LOCAL] After pkill, RSS={} (delta={})",
|
||||
|
|
@ -153,7 +153,7 @@ pub async fn ensure_llama_servers_running(
|
|||
task.await??;
|
||||
}
|
||||
info!("Waiting for servers to become ready...");
|
||||
trace!("[PROFILE] Starting wait loop for servers...");
|
||||
trace!("Starting wait loop for servers...");
|
||||
let before_wait = MemoryStats::current();
|
||||
trace!("[LLM_LOCAL] Before wait loop, RSS={}", MemoryStats::format_bytes(before_wait.rss_bytes));
|
||||
|
||||
|
|
@ -162,7 +162,7 @@ pub async fn ensure_llama_servers_running(
|
|||
let mut attempts = 0;
|
||||
let max_attempts = 120;
|
||||
while attempts < max_attempts && (!llm_ready || !embedding_ready) {
|
||||
trace!("[PROFILE] Wait loop iteration {}", attempts);
|
||||
trace!("Wait loop iteration {}", attempts);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
if attempts % 5 == 0 {
|
||||
|
|
@ -221,7 +221,7 @@ pub async fn ensure_llama_servers_running(
|
|||
if !embedding_model.is_empty() {
|
||||
set_embedding_server_ready(true);
|
||||
}
|
||||
trace!("[PROFILE] Servers ready!");
|
||||
trace!("Servers ready!");
|
||||
|
||||
let after_ready = MemoryStats::current();
|
||||
trace!("[LLM_LOCAL] Servers ready, RSS={} (delta from start={})",
|
||||
|
|
@ -240,7 +240,7 @@ pub async fn ensure_llama_servers_running(
|
|||
MemoryStats::format_bytes(end_mem.rss_bytes.saturating_sub(start_mem.rss_bytes)));
|
||||
log_jemalloc_stats();
|
||||
|
||||
trace!("[PROFILE] ensure_llama_servers_running EXIT OK");
|
||||
trace!("ensure_llama_servers_running EXIT OK");
|
||||
Ok(())
|
||||
} else {
|
||||
let mut error_msg = "Servers failed to start within timeout:".to_string();
|
||||
|
|
|
|||
187
src/main.rs
187
src/main.rs
|
|
@ -26,7 +26,20 @@ use tower_http::trace::TraceLayer;
|
|||
async fn ensure_vendor_files_in_minio(drive: &aws_sdk_s3::Client) {
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
|
||||
let htmx_content = include_bytes!("../botserver-stack/static/js/vendor/htmx.min.js");
|
||||
let htmx_paths = [
|
||||
"./botui/ui/suite/js/vendor/htmx.min.js",
|
||||
"../botui/ui/suite/js/vendor/htmx.min.js",
|
||||
];
|
||||
|
||||
let htmx_content = htmx_paths
|
||||
.iter()
|
||||
.find_map(|path| std::fs::read(path).ok());
|
||||
|
||||
let Some(content) = htmx_content else {
|
||||
warn!("Could not find htmx.min.js in botui, skipping MinIO upload");
|
||||
return;
|
||||
};
|
||||
|
||||
let bucket = "default.gbai";
|
||||
let key = "default.gblib/vendor/htmx.min.js";
|
||||
|
||||
|
|
@ -34,7 +47,7 @@ async fn ensure_vendor_files_in_minio(drive: &aws_sdk_s3::Client) {
|
|||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(htmx_content))
|
||||
.body(ByteStream::from(content))
|
||||
.content_type("application/javascript")
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -222,20 +235,28 @@ async fn run_axum_server(
|
|||
.add_public_path("/apps")); // Apps are public - no auth required
|
||||
|
||||
use crate::core::urls::ApiUrls;
|
||||
use crate::core::product::{PRODUCT_CONFIG, get_product_config_json};
|
||||
|
||||
// Initialize product configuration
|
||||
{
|
||||
let config = PRODUCT_CONFIG.read().expect("Failed to read product config");
|
||||
info!("Product: {} | Theme: {} | Apps: {:?}",
|
||||
config.name, config.theme, config.get_enabled_apps());
|
||||
}
|
||||
|
||||
// Product config endpoint
|
||||
async fn get_product_config() -> Json<serde_json::Value> {
|
||||
Json(get_product_config_json())
|
||||
}
|
||||
|
||||
let mut api_router = Router::new()
|
||||
.route("/health", get(health_check_simple))
|
||||
.route(ApiUrls::HEALTH, get(health_check))
|
||||
.route("/api/product", get(get_product_config))
|
||||
.route(ApiUrls::SESSIONS, post(create_session))
|
||||
.route(ApiUrls::SESSIONS, get(get_sessions))
|
||||
.route(
|
||||
&ApiUrls::SESSION_HISTORY.replace(":id", "{session_id}"),
|
||||
get(get_session_history),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::SESSION_START.replace(":id", "{session_id}"),
|
||||
post(start_session),
|
||||
)
|
||||
.route(ApiUrls::SESSION_HISTORY, get(get_session_history))
|
||||
.route(ApiUrls::SESSION_START, post(start_session))
|
||||
.route(ApiUrls::WS, get(websocket_handler))
|
||||
.merge(botserver::drive::configure());
|
||||
|
||||
|
|
@ -244,7 +265,8 @@ async fn run_axum_server(
|
|||
api_router = api_router
|
||||
.route(ApiUrls::AUTH, get(auth_handler))
|
||||
.merge(crate::core::directory::api::configure_user_routes())
|
||||
.merge(crate::directory::router::configure());
|
||||
.merge(crate::directory::router::configure())
|
||||
.merge(crate::directory::auth_routes::configure());
|
||||
}
|
||||
|
||||
#[cfg(feature = "meet")]
|
||||
|
|
@ -280,7 +302,11 @@ async fn run_axum_server(
|
|||
}
|
||||
|
||||
api_router = api_router.merge(botserver::analytics::configure_analytics_routes());
|
||||
api_router = api_router.merge(crate::core::i18n::configure_i18n_routes());
|
||||
api_router = api_router.merge(botserver::docs::configure_docs_routes());
|
||||
api_router = api_router.merge(botserver::paper::configure_paper_routes());
|
||||
api_router = api_router.merge(botserver::sheet::configure_sheet_routes());
|
||||
api_router = api_router.merge(botserver::slides::configure_slides_routes());
|
||||
api_router = api_router.merge(botserver::research::configure_research_routes());
|
||||
api_router = api_router.merge(botserver::sources::configure_sources_routes());
|
||||
api_router = api_router.merge(botserver::designer::configure_designer_routes());
|
||||
|
|
@ -289,12 +315,18 @@ async fn run_axum_server(
|
|||
api_router = api_router.merge(botserver::basic::keywords::configure_db_routes());
|
||||
api_router = api_router.merge(botserver::basic::keywords::configure_app_server_routes());
|
||||
api_router = api_router.merge(botserver::auto_task::configure_autotask_routes());
|
||||
api_router = api_router.merge(crate::core::shared::admin::configure());
|
||||
|
||||
#[cfg(feature = "whatsapp")]
|
||||
{
|
||||
api_router = api_router.merge(crate::whatsapp::configure());
|
||||
}
|
||||
|
||||
#[cfg(feature = "telegram")]
|
||||
{
|
||||
api_router = api_router.merge(botserver::telegram::configure());
|
||||
}
|
||||
|
||||
#[cfg(feature = "attendance")]
|
||||
{
|
||||
api_router = api_router.merge(crate::attendance::configure_attendance_routes());
|
||||
|
|
@ -331,6 +363,10 @@ async fn run_axum_server(
|
|||
|
||||
info!("Security middleware enabled: rate limiting, security headers, panic handler, request ID tracking, authentication");
|
||||
|
||||
// Path to UI files (botui)
|
||||
let ui_path = std::env::var("BOTUI_PATH").unwrap_or_else(|_| "./botui/ui/suite".to_string());
|
||||
info!("Serving UI from: {}", ui_path);
|
||||
|
||||
let app = Router::new()
|
||||
.merge(api_router.with_state(app_state.clone()))
|
||||
// Authentication middleware for protected routes
|
||||
|
|
@ -338,6 +374,8 @@ async fn run_axum_server(
|
|||
auth_config.clone(),
|
||||
auth_middleware,
|
||||
))
|
||||
// Serve auth UI pages
|
||||
.nest_service("/auth", ServeDir::new(format!("{}/auth", ui_path)))
|
||||
// Static files fallback for legacy /apps/* paths
|
||||
.nest_service("/static", ServeDir::new(&site_path))
|
||||
// Security middleware stack (order matters - first added is outermost)
|
||||
|
|
@ -486,6 +524,21 @@ async fn main() -> std::io::Result<()> {
|
|||
println!("Starting General Bots {}...", env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
let locales_path = if std::path::Path::new("./locales").exists() {
|
||||
"./locales"
|
||||
} else if std::path::Path::new("../botlib/locales").exists() {
|
||||
"../botlib/locales"
|
||||
} else if std::path::Path::new("../locales").exists() {
|
||||
"../locales"
|
||||
} else {
|
||||
"./locales"
|
||||
};
|
||||
if let Err(e) = crate::core::i18n::init_i18n(locales_path) {
|
||||
warn!("Failed to initialize i18n from {}: {}. Translations will show keys.", locales_path, e);
|
||||
} else {
|
||||
info!("i18n initialized from {} with locales: {:?}", locales_path, crate::core::i18n::available_locales());
|
||||
}
|
||||
|
||||
let (progress_tx, _progress_rx) = tokio::sync::mpsc::unbounded_channel::<BootstrapProgress>();
|
||||
let (state_tx, _state_rx) = tokio::sync::mpsc::channel::<Arc<AppState>>(1);
|
||||
|
||||
|
|
@ -760,20 +813,100 @@ async fn main() -> std::io::Result<()> {
|
|||
)));
|
||||
|
||||
#[cfg(feature = "directory")]
|
||||
let zitadel_config = botserver::directory::client::ZitadelConfig {
|
||||
issuer_url: "https://localhost:8080".to_string(),
|
||||
issuer: "https://localhost:8080".to_string(),
|
||||
client_id: "client_id".to_string(),
|
||||
client_secret: "client_secret".to_string(),
|
||||
redirect_uri: "https://localhost:8080/callback".to_string(),
|
||||
let zitadel_config = {
|
||||
// Try to load from directory_config.json first
|
||||
let config_path = "./config/directory_config.json";
|
||||
if let Ok(content) = std::fs::read_to_string(config_path) {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let base_url = json.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("http://localhost:8300");
|
||||
let client_id = json.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let client_secret = json.get("client_secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
info!("Loaded Zitadel config from {}: url={}", config_path, base_url);
|
||||
|
||||
botserver::directory::client::ZitadelConfig {
|
||||
issuer_url: base_url.to_string(),
|
||||
issuer: base_url.to_string(),
|
||||
client_id: client_id.to_string(),
|
||||
client_secret: client_secret.to_string(),
|
||||
redirect_uri: format!("{}/callback", base_url),
|
||||
project_id: "default".to_string(),
|
||||
api_url: "https://localhost:8080".to_string(),
|
||||
api_url: base_url.to_string(),
|
||||
service_account_key: None,
|
||||
}
|
||||
} else {
|
||||
warn!("Failed to parse directory_config.json, using defaults");
|
||||
botserver::directory::client::ZitadelConfig {
|
||||
issuer_url: "http://localhost:8300".to_string(),
|
||||
issuer: "http://localhost:8300".to_string(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
redirect_uri: "http://localhost:8300/callback".to_string(),
|
||||
project_id: "default".to_string(),
|
||||
api_url: "http://localhost:8300".to_string(),
|
||||
service_account_key: None,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("directory_config.json not found, using default Zitadel config");
|
||||
botserver::directory::client::ZitadelConfig {
|
||||
issuer_url: "http://localhost:8300".to_string(),
|
||||
issuer: "http://localhost:8300".to_string(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
redirect_uri: "http://localhost:8300/callback".to_string(),
|
||||
project_id: "default".to_string(),
|
||||
api_url: "http://localhost:8300".to_string(),
|
||||
service_account_key: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(feature = "directory")]
|
||||
let auth_service = Arc::new(tokio::sync::Mutex::new(
|
||||
botserver::directory::AuthService::new(zitadel_config).map_err(|e| std::io::Error::other(format!("Failed to create auth service: {}", e)))?,
|
||||
botserver::directory::AuthService::new(zitadel_config.clone()).map_err(|e| std::io::Error::other(format!("Failed to create auth service: {}", e)))?,
|
||||
));
|
||||
|
||||
#[cfg(feature = "directory")]
|
||||
{
|
||||
let pat_path = std::path::Path::new("./botserver-stack/conf/directory/admin-pat.txt");
|
||||
let bootstrap_client = if pat_path.exists() {
|
||||
match std::fs::read_to_string(pat_path) {
|
||||
Ok(pat_token) => {
|
||||
let pat_token = pat_token.trim().to_string();
|
||||
info!("Using admin PAT token for bootstrap authentication");
|
||||
botserver::directory::client::ZitadelClient::with_pat_token(zitadel_config, pat_token)
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to create bootstrap client with PAT: {}", e)))?
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to read admin PAT token: {}, falling back to OAuth2", e);
|
||||
botserver::directory::client::ZitadelClient::new(zitadel_config)
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to create bootstrap client: {}", e)))?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("Admin PAT not found, using OAuth2 client credentials for bootstrap");
|
||||
botserver::directory::client::ZitadelClient::new(zitadel_config)
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to create bootstrap client: {}", e)))?
|
||||
};
|
||||
|
||||
match botserver::directory::bootstrap::check_and_bootstrap_admin(&bootstrap_client).await {
|
||||
Ok(Some(_)) => {
|
||||
info!("Bootstrap completed - admin credentials displayed in console");
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("Admin user exists, bootstrap skipped");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Bootstrap check failed (Zitadel may not be ready): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let config_manager = ConfigManager::new(pool.clone());
|
||||
|
||||
let mut bot_conn = pool.get().map_err(|e| std::io::Error::other(format!("Failed to get database connection: {}", e)))?;
|
||||
|
|
@ -961,18 +1094,18 @@ async fn main() -> std::io::Result<()> {
|
|||
let monitor_bot_id = default_bot_id;
|
||||
tokio::spawn(async move {
|
||||
register_thread("drive-monitor", "drive");
|
||||
trace!("[PROFILE] DriveMonitor::new starting...");
|
||||
trace!("DriveMonitor::new starting...");
|
||||
let monitor = botserver::DriveMonitor::new(
|
||||
drive_monitor_state,
|
||||
bucket_name.clone(),
|
||||
monitor_bot_id,
|
||||
);
|
||||
trace!("[PROFILE] DriveMonitor::new done, calling start_monitoring...");
|
||||
trace!("DriveMonitor::new done, calling start_monitoring...");
|
||||
info!("Starting DriveMonitor for bucket: {}", bucket_name);
|
||||
if let Err(e) = monitor.start_monitoring().await {
|
||||
error!("DriveMonitor failed: {}", e);
|
||||
}
|
||||
trace!("[PROFILE] DriveMonitor start_monitoring returned");
|
||||
trace!("DriveMonitor start_monitoring returned");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -994,23 +1127,23 @@ async fn main() -> std::io::Result<()> {
|
|||
let app_state_for_llm = app_state.clone();
|
||||
tokio::spawn(async move {
|
||||
register_thread("llm-server-init", "llm");
|
||||
eprintln!("[PROFILE] ensure_llama_servers_running starting...");
|
||||
trace!("ensure_llama_servers_running starting...");
|
||||
if let Err(e) = ensure_llama_servers_running(app_state_for_llm).await {
|
||||
error!("Failed to start LLM servers: {}", e);
|
||||
}
|
||||
eprintln!("[PROFILE] ensure_llama_servers_running completed");
|
||||
trace!("ensure_llama_servers_running completed");
|
||||
record_thread_activity("llm-server-init");
|
||||
});
|
||||
trace!("Initial data setup task spawned");
|
||||
eprintln!("[PROFILE] All background tasks spawned, starting HTTP server...");
|
||||
trace!("All background tasks spawned, starting HTTP server...");
|
||||
|
||||
trace!("Starting HTTP server on port {}...", config.server.port);
|
||||
eprintln!("[PROFILE] run_axum_server starting on port {}...", config.server.port);
|
||||
info!("Starting HTTP server on port {}...", config.server.port);
|
||||
if let Err(e) = run_axum_server(app_state, config.server.port, worker_count).await {
|
||||
error!("Failed to start HTTP server: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
eprintln!("[PROFILE] run_axum_server returned (should not happen normally)");
|
||||
trace!("run_axum_server returned (should not happen normally)");
|
||||
|
||||
if let Some(handle) = ui_handle {
|
||||
handle.join().ok();
|
||||
|
|
|
|||
|
|
@ -26,18 +26,9 @@ pub fn configure() -> Router<Arc<AppState>> {
|
|||
.route(ApiUrls::MEET_PARTICIPANTS, get(all_participants))
|
||||
.route(ApiUrls::MEET_RECENT, get(recent_meetings))
|
||||
.route(ApiUrls::MEET_SCHEDULED, get(scheduled_meetings))
|
||||
.route(
|
||||
&ApiUrls::MEET_ROOM_BY_ID.replace(":id", "{room_id}"),
|
||||
get(get_room),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::MEET_JOIN.replace(":id", "{room_id}"),
|
||||
post(join_room),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::MEET_TRANSCRIPTION.replace(":id", "{room_id}"),
|
||||
post(start_transcription),
|
||||
)
|
||||
.route(ApiUrls::MEET_ROOM_BY_ID, get(get_room))
|
||||
.route(ApiUrls::MEET_JOIN, post(join_room))
|
||||
.route(ApiUrls::MEET_TRANSCRIPTION, post(start_transcription))
|
||||
.route(ApiUrls::MEET_TOKEN, post(get_meeting_token))
|
||||
.route(ApiUrls::MEET_INVITE, post(send_meeting_invites))
|
||||
.route(ApiUrls::WS_MEET, get(meeting_websocket))
|
||||
|
|
@ -46,7 +37,7 @@ pub fn configure() -> Router<Arc<AppState>> {
|
|||
post(conversations::create_conversation),
|
||||
)
|
||||
.route(
|
||||
"/conversations/{id}/join",
|
||||
"/conversations/:id/join",
|
||||
post(conversations::join_conversation),
|
||||
)
|
||||
.route(
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@ pub fn configure_paper_routes() -> Router<Arc<AppState>> {
|
|||
.route(ApiUrls::PAPER_SEARCH, get(handle_search_documents))
|
||||
.route(ApiUrls::PAPER_SAVE, post(handle_save_document))
|
||||
.route(ApiUrls::PAPER_AUTOSAVE, post(handle_autosave))
|
||||
.route(&ApiUrls::PAPER_BY_ID.replace(":id", "{id}"), get(handle_get_document))
|
||||
.route(&ApiUrls::PAPER_DELETE.replace(":id", "{id}"), post(handle_delete_document))
|
||||
.route(ApiUrls::PAPER_BY_ID, get(handle_get_document))
|
||||
.route(ApiUrls::PAPER_DELETE, post(handle_delete_document))
|
||||
.route(ApiUrls::PAPER_TEMPLATE_BLANK, post(handle_template_blank))
|
||||
.route(ApiUrls::PAPER_TEMPLATE_MEETING, post(handle_template_meeting))
|
||||
.route(ApiUrls::PAPER_TEMPLATE_TODO, post(handle_template_todo))
|
||||
|
|
@ -96,6 +96,8 @@ pub fn configure_paper_routes() -> Router<Arc<AppState>> {
|
|||
ApiUrls::PAPER_TEMPLATE_RESEARCH,
|
||||
post(handle_template_research),
|
||||
)
|
||||
.route(ApiUrls::PAPER_TEMPLATE_REPORT, post(handle_template_report))
|
||||
.route(ApiUrls::PAPER_TEMPLATE_LETTER, post(handle_template_letter))
|
||||
.route(ApiUrls::PAPER_AI_SUMMARIZE, post(handle_ai_summarize))
|
||||
.route(ApiUrls::PAPER_AI_EXPAND, post(handle_ai_expand))
|
||||
.route(ApiUrls::PAPER_AI_IMPROVE, post(handle_ai_improve))
|
||||
|
|
@ -876,6 +878,83 @@ pub async fn handle_template_research(
|
|||
Html(format_document_content(&title, &content))
|
||||
}
|
||||
|
||||
pub async fn handle_template_report(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let (_user_id, user_identifier) = match get_current_user(&state, &headers).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
log::error!("Auth error: {}", e);
|
||||
return Html(format_error("Authentication required"));
|
||||
}
|
||||
};
|
||||
|
||||
let doc_id = Uuid::new_v4().to_string();
|
||||
let title = "Report".to_string();
|
||||
let now = Utc::now();
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("# Report\n\n");
|
||||
let _ = writeln!(content, "**Date:** {}\n", now.format("%Y-%m-%d"));
|
||||
content.push_str("**Author:**\n\n");
|
||||
content.push_str("---\n\n");
|
||||
content.push_str("## Executive Summary\n\n\n\n");
|
||||
content.push_str("## Introduction\n\n\n\n");
|
||||
content.push_str("## Background\n\n\n\n");
|
||||
content.push_str("## Findings\n\n### Key Finding 1\n\n\n\n### Key Finding 2\n\n\n\n");
|
||||
content.push_str("## Analysis\n\n\n\n");
|
||||
content.push_str("## Recommendations\n\n1. \n2. \n3. \n\n");
|
||||
content.push_str("## Conclusion\n\n\n\n");
|
||||
content.push_str("## Appendix\n\n");
|
||||
|
||||
let _ =
|
||||
save_document_to_drive(&state, &user_identifier, &doc_id, &title, &content, false).await;
|
||||
|
||||
Html(format_document_content(&title, &content))
|
||||
}
|
||||
|
||||
pub async fn handle_template_letter(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let (_user_id, user_identifier) = match get_current_user(&state, &headers).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
log::error!("Auth error: {}", e);
|
||||
return Html(format_error("Authentication required"));
|
||||
}
|
||||
};
|
||||
|
||||
let doc_id = Uuid::new_v4().to_string();
|
||||
let title = "Letter".to_string();
|
||||
let now = Utc::now();
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("[Your Name]\n");
|
||||
content.push_str("[Your Address]\n");
|
||||
content.push_str("[City, State ZIP]\n");
|
||||
content.push_str("[Your Email]\n\n");
|
||||
let _ = writeln!(content, "{}\n", now.format("%B %d, %Y"));
|
||||
content.push_str("[Recipient Name]\n");
|
||||
content.push_str("[Recipient Title]\n");
|
||||
content.push_str("[Company/Organization]\n");
|
||||
content.push_str("[Address]\n");
|
||||
content.push_str("[City, State ZIP]\n\n");
|
||||
content.push_str("Dear [Recipient Name],\n\n");
|
||||
content.push_str("[Opening paragraph - State the purpose of your letter]\n\n");
|
||||
content.push_str("[Body paragraph(s) - Provide details, explanations, or supporting information]\n\n");
|
||||
content.push_str("[Closing paragraph - Summarize, request action, or express appreciation]\n\n");
|
||||
content.push_str("Sincerely,\n\n\n");
|
||||
content.push_str("[Your Signature]\n");
|
||||
content.push_str("[Your Typed Name]\n");
|
||||
|
||||
let _ =
|
||||
save_document_to_drive(&state, &user_identifier, &doc_id, &title, &content, false).await;
|
||||
|
||||
Html(format_document_content(&title, &content))
|
||||
}
|
||||
|
||||
pub async fn handle_ai_summarize(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<AiRequest>,
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ pub fn configure_research_routes() -> Router<Arc<AppState>> {
|
|||
ApiUrls::RESEARCH_COLLECTIONS_NEW,
|
||||
post(handle_create_collection),
|
||||
)
|
||||
.route(&ApiUrls::RESEARCH_COLLECTION_BY_ID.replace(":id", "{id}"), get(handle_get_collection))
|
||||
.route(ApiUrls::RESEARCH_COLLECTION_BY_ID, get(handle_get_collection))
|
||||
.route(ApiUrls::RESEARCH_SEARCH, post(handle_search))
|
||||
.route(ApiUrls::RESEARCH_RECENT, get(handle_recent_searches))
|
||||
.route(ApiUrls::RESEARCH_TRENDING, get(handle_trending_tags))
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ pub struct ZitadelAuthConfig {
|
|||
impl Default for ZitadelAuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
issuer_url: "https://localhost:8080".to_string(),
|
||||
api_url: "https://localhost:8080".to_string(),
|
||||
issuer_url: "http://localhost:8300".to_string(),
|
||||
api_url: "http://localhost:8300".to_string(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
project_id: String::new(),
|
||||
|
|
|
|||
2854
src/sheet/mod.rs
Normal file
2854
src/sheet/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
1360
src/slides/mod.rs
Normal file
1360
src/slides/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@ use crate::shared::state::AppState;
|
|||
use axum::{
|
||||
extract::{Multipart, Path, Query, State},
|
||||
response::{Html, IntoResponse},
|
||||
routing::{delete, get, post},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
|
@ -235,8 +235,7 @@ pub fn configure_knowledge_base_routes() -> Router<Arc<AppState>> {
|
|||
.route(ApiUrls::SOURCES_KB_UPLOAD, post(handle_upload_document))
|
||||
.route(ApiUrls::SOURCES_KB_LIST, get(handle_list_sources))
|
||||
.route(ApiUrls::SOURCES_KB_QUERY, post(handle_query_knowledge_base))
|
||||
.route(&ApiUrls::SOURCES_KB_BY_ID.replace(":id", "{id}"), get(handle_get_source))
|
||||
.route(&ApiUrls::SOURCES_KB_BY_ID.replace(":id", "{id}"), delete(handle_delete_source))
|
||||
.route(ApiUrls::SOURCES_KB_BY_ID, get(handle_get_source).delete(handle_delete_source))
|
||||
.route(ApiUrls::SOURCES_KB_REINDEX, post(handle_reindex_sources))
|
||||
.route(ApiUrls::SOURCES_KB_STATS, get(handle_get_stats))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use axum::{
|
|||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
routing::{delete, get, post, put},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use log::{error, info};
|
||||
|
|
@ -171,22 +171,11 @@ pub fn configure_sources_routes() -> Router<Arc<AppState>> {
|
|||
.route(ApiUrls::SOURCES_APPS, get(handle_list_apps))
|
||||
.route(ApiUrls::SOURCES_MCP, get(handle_list_mcp_servers_json))
|
||||
.route(ApiUrls::SOURCES_MCP, post(handle_add_mcp_server))
|
||||
.route(&ApiUrls::SOURCES_MCP_BY_NAME.replace(":name", "{name}"), get(handle_get_mcp_server))
|
||||
.route(&ApiUrls::SOURCES_MCP_BY_NAME.replace(":name", "{name}"), put(handle_update_mcp_server))
|
||||
.route(&ApiUrls::SOURCES_MCP_BY_NAME.replace(":name", "{name}"), delete(handle_delete_mcp_server))
|
||||
.route(
|
||||
&ApiUrls::SOURCES_MCP_ENABLE.replace(":name", "{name}"),
|
||||
post(handle_enable_mcp_server),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::SOURCES_MCP_DISABLE.replace(":name", "{name}"),
|
||||
post(handle_disable_mcp_server),
|
||||
)
|
||||
.route(
|
||||
&ApiUrls::SOURCES_MCP_TOOLS.replace(":name", "{name}"),
|
||||
get(handle_list_mcp_server_tools),
|
||||
)
|
||||
.route(&ApiUrls::SOURCES_MCP_TEST.replace(":name", "{name}"), post(handle_test_mcp_server))
|
||||
.route(ApiUrls::SOURCES_MCP_BY_NAME, get(handle_get_mcp_server).put(handle_update_mcp_server).delete(handle_delete_mcp_server))
|
||||
.route(ApiUrls::SOURCES_MCP_ENABLE, post(handle_enable_mcp_server))
|
||||
.route(ApiUrls::SOURCES_MCP_DISABLE, post(handle_disable_mcp_server))
|
||||
.route(ApiUrls::SOURCES_MCP_TOOLS, get(handle_list_mcp_server_tools))
|
||||
.route(ApiUrls::SOURCES_MCP_TEST, post(handle_test_mcp_server))
|
||||
.route(ApiUrls::SOURCES_MCP_SCAN, post(handle_scan_mcp_directory))
|
||||
.route(ApiUrls::SOURCES_MCP_EXAMPLES, get(handle_get_mcp_examples))
|
||||
.route(ApiUrls::SOURCES_MENTIONS, get(handle_mentions_autocomplete))
|
||||
|
|
@ -989,15 +978,6 @@ fn load_mcp_servers_catalog() -> Option<McpServersCatalog> {
|
|||
}
|
||||
}
|
||||
|
||||
fn get_type_badge_class(server_type: &str) -> &'static str {
|
||||
match server_type {
|
||||
"Local" => "badge-local",
|
||||
"Remote" => "badge-remote",
|
||||
"Custom" => "badge-custom",
|
||||
_ => "badge-default",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_category_icon(category: &str) -> &'static str {
|
||||
match category {
|
||||
"Database" => "🗄️",
|
||||
|
|
@ -1078,7 +1058,6 @@ pub async fn handle_mcp_servers(
|
|||
server.status,
|
||||
crate::basic::keywords::mcp_client::McpServerStatus::Active
|
||||
);
|
||||
let status_class = if is_active { "status-active" } else { "status-inactive" };
|
||||
let status_text = if is_active { "Active" } else { "Inactive" };
|
||||
|
||||
let status_bg = if is_active { "#e8f5e9" } else { "#ffebee" };
|
||||
|
|
|
|||
|
|
@ -2079,24 +2079,16 @@ pub fn configure_task_routes() -> Router<Arc<AppState>> {
|
|||
.route(ApiUrls::TASKS_STATS, get(handle_task_stats_htmx))
|
||||
.route(ApiUrls::TASKS_TIME_SAVED, get(handle_time_saved))
|
||||
.route(ApiUrls::TASKS_COMPLETED, delete(handle_clear_completed))
|
||||
.route(
|
||||
&ApiUrls::TASKS_GET_HTMX.replace(":id", "{id}"),
|
||||
get(handle_task_get),
|
||||
)
|
||||
.route(ApiUrls::TASKS_GET_HTMX, get(handle_task_get))
|
||||
// JSON API - Stats
|
||||
.route(ApiUrls::TASKS_STATS_JSON, get(handle_task_stats))
|
||||
// JSON API - Parameterized task routes
|
||||
.route(
|
||||
&ApiUrls::TASK_BY_ID.replace(":id", "{id}"),
|
||||
put(handle_task_update)
|
||||
.delete(handle_task_delete)
|
||||
.patch(handle_task_patch),
|
||||
)
|
||||
.route(&ApiUrls::TASK_ASSIGN.replace(":id", "{id}"), post(handle_task_assign))
|
||||
.route(&ApiUrls::TASK_STATUS.replace(":id", "{id}"), put(handle_task_status_update))
|
||||
.route(&ApiUrls::TASK_PRIORITY.replace(":id", "{id}"), put(handle_task_priority_set))
|
||||
.route("/api/tasks/{id}/dependencies", put(handle_task_set_dependencies))
|
||||
.route("/api/tasks/{id}/cancel", post(handle_task_cancel))
|
||||
.route(ApiUrls::TASK_BY_ID, put(handle_task_update).delete(handle_task_delete).patch(handle_task_patch))
|
||||
.route(ApiUrls::TASK_ASSIGN, post(handle_task_assign))
|
||||
.route(ApiUrls::TASK_STATUS, put(handle_task_status_update))
|
||||
.route(ApiUrls::TASK_PRIORITY, put(handle_task_priority_set))
|
||||
.route("/api/tasks/:id/dependencies", put(handle_task_set_dependencies))
|
||||
.route("/api/tasks/:id/cancel", post(handle_task_cancel))
|
||||
}
|
||||
|
||||
pub async fn handle_task_cancel(
|
||||
|
|
|
|||
539
src/telegram/mod.rs
Normal file
539
src/telegram/mod.rs
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
use crate::bot::BotOrchestrator;
|
||||
use crate::core::bot::channels::telegram::TelegramAdapter;
|
||||
use crate::core::bot::channels::ChannelAdapter;
|
||||
use crate::shared::models::{BotResponse, UserSession};
|
||||
use crate::shared::state::{AppState, AttendantNotification};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use log::{debug, error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramUpdate {
|
||||
pub update_id: i64,
|
||||
#[serde(default)]
|
||||
pub message: Option<TelegramMessage>,
|
||||
#[serde(default)]
|
||||
pub edited_message: Option<TelegramMessage>,
|
||||
#[serde(default)]
|
||||
pub callback_query: Option<TelegramCallbackQuery>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramMessage {
|
||||
pub message_id: i64,
|
||||
pub from: Option<TelegramUser>,
|
||||
pub chat: TelegramChat,
|
||||
pub date: i64,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub photo: Option<Vec<TelegramPhotoSize>>,
|
||||
#[serde(default)]
|
||||
pub document: Option<TelegramDocument>,
|
||||
#[serde(default)]
|
||||
pub voice: Option<TelegramVoice>,
|
||||
#[serde(default)]
|
||||
pub audio: Option<TelegramAudio>,
|
||||
#[serde(default)]
|
||||
pub video: Option<TelegramVideo>,
|
||||
#[serde(default)]
|
||||
pub location: Option<TelegramLocation>,
|
||||
#[serde(default)]
|
||||
pub contact: Option<TelegramContact>,
|
||||
#[serde(default)]
|
||||
pub caption: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramUser {
|
||||
pub id: i64,
|
||||
pub is_bot: bool,
|
||||
pub first_name: String,
|
||||
#[serde(default)]
|
||||
pub last_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub language_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramChat {
|
||||
pub id: i64,
|
||||
#[serde(rename = "type")]
|
||||
pub chat_type: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub first_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramPhotoSize {
|
||||
pub file_id: String,
|
||||
pub file_unique_id: String,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
#[serde(default)]
|
||||
pub file_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramDocument {
|
||||
pub file_id: String,
|
||||
pub file_unique_id: String,
|
||||
#[serde(default)]
|
||||
pub file_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramVoice {
|
||||
pub file_id: String,
|
||||
pub file_unique_id: String,
|
||||
pub duration: i32,
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramAudio {
|
||||
pub file_id: String,
|
||||
pub file_unique_id: String,
|
||||
pub duration: i32,
|
||||
#[serde(default)]
|
||||
pub performer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramVideo {
|
||||
pub file_id: String,
|
||||
pub file_unique_id: String,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub duration: i32,
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramLocation {
|
||||
pub longitude: f64,
|
||||
pub latitude: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramContact {
|
||||
pub phone_number: String,
|
||||
pub first_name: String,
|
||||
#[serde(default)]
|
||||
pub last_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TelegramCallbackQuery {
|
||||
pub id: String,
|
||||
pub from: TelegramUser,
|
||||
#[serde(default)]
|
||||
pub message: Option<TelegramMessage>,
|
||||
#[serde(default)]
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
pub fn configure() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/webhook/telegram", post(handle_webhook))
|
||||
.route("/api/telegram/send", post(send_message))
|
||||
}
|
||||
|
||||
pub async fn handle_webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(update): Json<TelegramUpdate>,
|
||||
) -> impl IntoResponse {
|
||||
info!("Telegram webhook received: update_id={}", update.update_id);
|
||||
|
||||
if let Some(message) = update.message.or(update.edited_message) {
|
||||
if let Err(e) = process_message(state.clone(), &message).await {
|
||||
error!("Failed to process Telegram message: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(callback) = update.callback_query {
|
||||
if let Err(e) = process_callback(state.clone(), &callback).await {
|
||||
error!("Failed to process Telegram callback: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
async fn process_message(
|
||||
state: Arc<AppState>,
|
||||
message: &TelegramMessage,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let chat_id = message.chat.id.to_string();
|
||||
let user = message.from.as_ref();
|
||||
|
||||
let user_name = user
|
||||
.map(|u| {
|
||||
let mut name = u.first_name.clone();
|
||||
if let Some(last) = &u.last_name {
|
||||
name.push(' ');
|
||||
name.push_str(last);
|
||||
}
|
||||
name
|
||||
})
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let content = extract_message_content(message);
|
||||
|
||||
if content.is_empty() {
|
||||
debug!("Empty message content, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Processing Telegram message from {} (chat_id={}): {}",
|
||||
user_name,
|
||||
chat_id,
|
||||
if content.len() > 50 { &content[..50] } else { &content }
|
||||
);
|
||||
|
||||
let session = find_or_create_session(&state, &chat_id, &user_name).await?;
|
||||
|
||||
let assigned_to = session
|
||||
.context_data
|
||||
.get("assigned_to")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
if assigned_to.is_some() {
|
||||
route_to_attendant(state.clone(), &session, &content, &chat_id, &user_name).await?;
|
||||
} else {
|
||||
route_to_bot(state.clone(), &session, &content, &chat_id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_message_content(message: &TelegramMessage) -> String {
|
||||
if let Some(text) = &message.text {
|
||||
return text.clone();
|
||||
}
|
||||
|
||||
if let Some(caption) = &message.caption {
|
||||
return caption.clone();
|
||||
}
|
||||
|
||||
if message.photo.is_some() {
|
||||
return "[Photo received]".to_string();
|
||||
}
|
||||
|
||||
if message.document.is_some() {
|
||||
return "[Document received]".to_string();
|
||||
}
|
||||
|
||||
if message.voice.is_some() {
|
||||
return "[Voice message received]".to_string();
|
||||
}
|
||||
|
||||
if message.audio.is_some() {
|
||||
return "[Audio received]".to_string();
|
||||
}
|
||||
|
||||
if message.video.is_some() {
|
||||
return "[Video received]".to_string();
|
||||
}
|
||||
|
||||
if let Some(location) = &message.location {
|
||||
return format!("[Location: {}, {}]", location.latitude, location.longitude);
|
||||
}
|
||||
|
||||
if let Some(contact) = &message.contact {
|
||||
return format!("[Contact: {} {}]", contact.first_name, contact.phone_number);
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
async fn process_callback(
|
||||
state: Arc<AppState>,
|
||||
callback: &TelegramCallbackQuery,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let chat_id = callback
|
||||
.message
|
||||
.as_ref()
|
||||
.map(|m| m.chat.id.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let user_name = {
|
||||
let mut name = callback.from.first_name.clone();
|
||||
if let Some(last) = &callback.from.last_name {
|
||||
name.push(' ');
|
||||
name.push_str(last);
|
||||
}
|
||||
name
|
||||
};
|
||||
|
||||
let data = callback.data.clone().unwrap_or_default();
|
||||
|
||||
if data.is_empty() || chat_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Processing Telegram callback from {} (chat_id={}): {}",
|
||||
user_name, chat_id, data
|
||||
);
|
||||
|
||||
let session = find_or_create_session(&state, &chat_id, &user_name).await?;
|
||||
|
||||
route_to_bot(state, &session, &data, &chat_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_or_create_session(
|
||||
state: &Arc<AppState>,
|
||||
chat_id: &str,
|
||||
user_name: &str,
|
||||
) -> Result<UserSession, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use crate::shared::models::schema::user_sessions::dsl::*;
|
||||
|
||||
let mut conn = state.conn.get()?;
|
||||
|
||||
let telegram_user_uuid = Uuid::new_v5(&Uuid::NAMESPACE_OID, format!("telegram:{}", chat_id).as_bytes());
|
||||
|
||||
let existing: Option<UserSession> = user_sessions
|
||||
.filter(user_id.eq(telegram_user_uuid))
|
||||
.order(updated_at.desc())
|
||||
.first(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(session) = existing {
|
||||
diesel::update(user_sessions.filter(id.eq(session.id)))
|
||||
.set(updated_at.eq(Utc::now()))
|
||||
.execute(&mut conn)?;
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
let bot_uuid = get_default_bot_id(state).await;
|
||||
let session_uuid = Uuid::new_v4();
|
||||
|
||||
let context = serde_json::json!({
|
||||
"channel": "telegram",
|
||||
"chat_id": chat_id,
|
||||
"name": user_name,
|
||||
});
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
diesel::insert_into(user_sessions)
|
||||
.values((
|
||||
id.eq(session_uuid),
|
||||
user_id.eq(telegram_user_uuid),
|
||||
bot_id.eq(bot_uuid),
|
||||
title.eq(format!("Telegram: {}", user_name)),
|
||||
context_data.eq(&context),
|
||||
created_at.eq(now),
|
||||
updated_at.eq(now),
|
||||
))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
info!("Created new Telegram session {} for chat_id {}", session_uuid, chat_id);
|
||||
|
||||
let new_session = user_sessions
|
||||
.filter(id.eq(session_uuid))
|
||||
.first(&mut conn)?;
|
||||
|
||||
Ok(new_session)
|
||||
}
|
||||
|
||||
async fn route_to_bot(
|
||||
state: Arc<AppState>,
|
||||
session: &UserSession,
|
||||
content: &str,
|
||||
chat_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!("Routing Telegram message to bot for session {}", session.id);
|
||||
|
||||
let user_message = botlib::models::UserMessage::text(
|
||||
session.bot_id.to_string(),
|
||||
chat_id.to_string(),
|
||||
session.id.to_string(),
|
||||
"telegram".to_string(),
|
||||
content.to_string(),
|
||||
);
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<BotResponse>(10);
|
||||
let orchestrator = BotOrchestrator::new(state.clone());
|
||||
|
||||
let adapter = TelegramAdapter::new(state.conn.clone(), session.bot_id);
|
||||
let chat_id_clone = chat_id.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(response) = rx.recv().await {
|
||||
let tg_response = BotResponse::new(
|
||||
response.bot_id,
|
||||
response.session_id,
|
||||
chat_id_clone.clone(),
|
||||
response.content,
|
||||
"telegram",
|
||||
);
|
||||
|
||||
if let Err(e) = adapter.send_message(tg_response).await {
|
||||
error!("Failed to send Telegram response: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(e) = orchestrator.stream_response(user_message, tx).await {
|
||||
error!("Bot processing error: {}", e);
|
||||
|
||||
let adapter = TelegramAdapter::new(state.conn.clone(), session.bot_id);
|
||||
let error_response = BotResponse::new(
|
||||
session.bot_id.to_string(),
|
||||
session.id.to_string(),
|
||||
chat_id.to_string(),
|
||||
"Sorry, I encountered an error processing your message. Please try again.",
|
||||
"telegram",
|
||||
);
|
||||
|
||||
if let Err(e) = adapter.send_message(error_response).await {
|
||||
error!("Failed to send error response: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn route_to_attendant(
|
||||
state: Arc<AppState>,
|
||||
session: &UserSession,
|
||||
content: &str,
|
||||
chat_id: &str,
|
||||
user_name: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!(
|
||||
"Routing Telegram message to attendant for session {}",
|
||||
session.id
|
||||
);
|
||||
|
||||
let assigned_to = session
|
||||
.context_data
|
||||
.get("assigned_to")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let notification = AttendantNotification {
|
||||
notification_type: "message".to_string(),
|
||||
session_id: session.id.to_string(),
|
||||
user_id: chat_id.to_string(),
|
||||
user_name: Some(user_name.to_string()),
|
||||
user_phone: Some(chat_id.to_string()),
|
||||
channel: "telegram".to_string(),
|
||||
content: content.to_string(),
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
assigned_to,
|
||||
priority: 1,
|
||||
};
|
||||
|
||||
if let Some(broadcast_tx) = state.attendant_broadcast.as_ref() {
|
||||
if let Err(e) = broadcast_tx.send(notification.clone()) {
|
||||
debug!("No attendants listening: {}", e);
|
||||
} else {
|
||||
info!("Notification sent to attendants");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_default_bot_id(state: &Arc<AppState>) -> Uuid {
|
||||
use crate::shared::models::schema::bots::dsl::*;
|
||||
|
||||
if let Ok(mut conn) = state.conn.get() {
|
||||
if let Ok(bot_uuid) = bots
|
||||
.filter(is_active.eq(true))
|
||||
.select(id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
{
|
||||
return bot_uuid;
|
||||
}
|
||||
}
|
||||
|
||||
Uuid::parse_str("f47ac10b-58cc-4372-a567-0e02b2c3d480").unwrap_or_else(|_| Uuid::new_v4())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SendMessageRequest {
|
||||
pub to: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<SendMessageRequest>,
|
||||
) -> impl IntoResponse {
|
||||
info!("Sending Telegram message to {}", request.to);
|
||||
|
||||
let bot_id = get_default_bot_id(&state).await;
|
||||
let adapter = TelegramAdapter::new(state.conn.clone(), bot_id);
|
||||
|
||||
let response = BotResponse::new(
|
||||
bot_id.to_string(),
|
||||
Uuid::new_v4().to_string(),
|
||||
request.to.clone(),
|
||||
request.message.clone(),
|
||||
"telegram",
|
||||
);
|
||||
|
||||
match adapter.send_message(response).await {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": "Message sent successfully"
|
||||
})),
|
||||
),
|
||||
Err(e) => {
|
||||
error!("Failed to send Telegram message: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": "Failed to send message"
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue