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:
Rodrigo Rodriguez (Pragmatismo) 2026-01-06 22:56:35 -03:00
parent 29b80f597c
commit 479950945b
42 changed files with 10666 additions and 411 deletions

42
.product Normal file
View 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.

View file

@ -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 = []

View 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"
}

View file

@ -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)]

View file

@ -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(

View file

@ -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)

View file

@ -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(

View file

@ -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))

View file

@ -1,5 +1,6 @@
pub mod instagram;
pub mod teams;
pub mod telegram;
pub mod whatsapp;
use crate::shared::models::BotResponse;

View 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
View 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());
}
}

View file

@ -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;

View file

@ -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 {

View file

@ -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
View 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"));
}
}

View file

@ -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(),

View file

@ -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 {

View file

@ -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";

View file

@ -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\

View 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(&params)
.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
View 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"
);
}
}
}
}

View file

@ -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(())
}
}

View file

@ -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;

View file

@ -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(

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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(())
}

View file

@ -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))

View file

@ -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(())
}

View file

@ -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::*;

View file

@ -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();

View file

@ -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();

View file

@ -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(

View file

@ -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>,

View file

@ -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))

View file

@ -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

File diff suppressed because it is too large Load diff

1360
src/slides/mod.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -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))
}

View file

@ -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" };

View file

@ -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
View 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"
})),
)
}
}
}