Compare commits

...

2 commits

Author SHA1 Message Date
90c14bcd09 Fix DETECT: use bot-specific DB pool, add anonymous auth when directory disabled
All checks were successful
BotServer CI/CD / build (push) Successful in 12m42s
2026-04-06 13:37:23 -03:00
8d3c28e441 Fix SMTP: use starttls_relay for port 587, relay for 465 2026-04-06 11:00:23 -03:00
25 changed files with 548 additions and 264 deletions

View file

@ -10,7 +10,12 @@ features = ["database", "i18n"]
[features]
# ===== DEFAULT =====
default = ["chat", "people", "automation", "drive", "tasks", "cache", "directory", "llm", "crawler", "browser", "terminal", "editor", "mail", "whatsapp", "designer", "marketing", "goals", "analytics", "vectordb", "research"]
default = ["chat", "automation", "cache", "llm"]
# ===== SECURITY MODES =====
# no-security: Minimal build - chat, automation, drive, cache only (no RBAC, directory, security, compliance)
# Build with: cargo build --no-default-features --features "no-security,chat,llm"
no-security = []
browser = ["automation", "drive", "cache"]
terminal = ["automation", "drive", "cache"]
@ -21,7 +26,8 @@ scripting = ["dep:rhai"]
automation = ["scripting", "dep:cron"]
drive = ["dep:aws-config", "dep:aws-sdk-s3", "dep:aws-smithy-async", "dep:pdf-extract", "dep:notify"]
cache = ["dep:redis"]
directory = []
directory = ["rbac"]
rbac = []
crawler = ["drive", "cache"]
# ===== APPS (Each includes what it needs from core) =====
@ -90,6 +96,7 @@ console = ["automation", "drive", "cache", "dep:crossterm", "dep:ratatui"]
# ===== BUNDLES (Optional - for convenience) =====
minimal = ["chat"]
minimal-chat = ["chat", "automation", "drive", "cache"] # No security at all
lightweight = ["chat", "tasks", "people"]
full = ["chat", "people", "mail", "tasks", "calendar", "drive", "docs", "llm", "cache", "compliance"]
embed-ui = ["dep:rust-embed"]

View file

@ -7,6 +7,9 @@ PARAM horario AS STRING LIKE "10:00" DESCRIPTION "Horário preferencial (formato
PARAM numeroVisitantes AS INTEGER LIKE "3" DESCRIPTION "Número de visitantes"
id = "VIS-" + FORMAT(NOW(), "yyyyMMdd") + "-" + FORMAT(RANDOM(1000, 9999), "0000")
protocoloNumero = "VIS" + FORMAT(RANDOM(100000, 999999), "000000")
dataCadastro = FORMAT(NOW(), "yyyy-MM-dd HH:mm:ss")

View file

@ -4,15 +4,15 @@
"input_schema": {
"type": "object",
"properties": {
"telefone": {
"horario": {
"type": "string",
"description": "Telefone com DDD",
"example": "(21) 99999-9999"
"description": "Horário preferencial (formato HH:MM, entre 8h e 17h)",
"example": "10:00"
},
"email": {
"nomeResponsavel": {
"type": "string",
"description": "Email para contato",
"example": "joao@example.com"
"description": "Nome do responsável",
"example": "João Silva"
},
"dataVisita": {
"type": "string",
@ -20,20 +20,20 @@
"example": "2026-03-15",
"format": "date"
},
"horario": {
"email": {
"type": "string",
"description": "Horário preferencial (formato HH:MM, entre 8h e 17h)",
"example": "10:00"
"description": "Email para contato",
"example": "joao@example.com"
},
"numeroVisitantes": {
"type": "integer",
"description": "Número de visitantes",
"example": "3"
},
"nomeResponsavel": {
"telefone": {
"type": "string",
"description": "Nome do responsável",
"example": "João Silva"
"description": "Telefone com DDD",
"example": "(21) 99999-9999"
}
},
"required": [

View file

@ -6,11 +6,15 @@
"parameters": {
"type": "object",
"properties": {
"dataVisita": {
"nomeResponsavel": {
"type": "string",
"description": "Data desejada para visita (formato ISO: YYYY-MM-DD)",
"example": "2026-03-15",
"format": "date"
"description": "Nome do responsável",
"example": "João Silva"
},
"email": {
"type": "string",
"description": "Email para contato",
"example": "joao@example.com"
},
"numeroVisitantes": {
"type": "integer",
@ -22,15 +26,11 @@
"description": "Telefone com DDD",
"example": "(21) 99999-9999"
},
"email": {
"dataVisita": {
"type": "string",
"description": "Email para contato",
"example": "joao@example.com"
},
"nomeResponsavel": {
"type": "string",
"description": "Nome do responsável",
"example": "João Silva"
"description": "Data desejada para visita (formato ISO: YYYY-MM-DD)",
"example": "2026-03-15",
"format": "date"
},
"horario": {
"type": "string",

View file

@ -4,11 +4,6 @@
"input_schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Email para contato",
"example": "joao@example.com"
},
"nome": {
"type": "string",
"description": "Nome do interessado",
@ -24,6 +19,11 @@
"description": "Mensagem completa",
"example": "Gostaria de saber as formas de pagamento disponíveis"
},
"email": {
"type": "string",
"description": "Email para contato",
"example": "joao@example.com"
},
"telefone": {
"type": "string",
"description": "Telefone para retorno",

View file

@ -4,46 +4,46 @@
"input_schema": {
"type": "object",
"properties": {
"dataNascimento": {
"type": "string",
"description": "Data de nascimento (formato ISO: YYYY-MM-DD)",
"example": "2015-03-15",
"format": "date"
},
"email": {
"type": "string",
"description": "Email para contato",
"example": "maria.santos@example.com"
},
"endereco": {
"type": "string",
"description": "Endereço completo",
"example": "Rua das Flores, 123 - Centro"
},
"nomeCompleto": {
"type": "string",
"description": "Nome completo do aluno",
"example": "João Silva Santos"
},
"telefone": {
"type": "string",
"description": "Telefone com DDD",
"example": "(21) 99999-9999"
},
"turno": {
"type": "string",
"description": "Turno: MANHA ou TARDE",
"example": "MANHA"
},
"serie": {
"type": "string",
"description": "Série desejada",
"example": "8º ano"
},
"nomeCompleto": {
"type": "string",
"description": "Nome completo do aluno",
"example": "João Silva Santos"
},
"endereco": {
"type": "string",
"description": "Endereço completo",
"example": "Rua das Flores, 123 - Centro"
},
"nomeResponsavel": {
"type": "string",
"description": "Nome do responsável",
"example": "Maria Silva Santos"
},
"email": {
"type": "string",
"description": "Email para contato",
"example": "maria.santos@example.com"
},
"turno": {
"type": "string",
"description": "Turno: MANHA ou TARDE",
"example": "MANHA"
},
"dataNascimento": {
"type": "string",
"description": "Data de nascimento (formato ISO: YYYY-MM-DD)",
"example": "2015-03-15",
"format": "date"
}
},
"required": [

View file

@ -6,27 +6,6 @@
"parameters": {
"type": "object",
"properties": {
"nomeCompleto": {
"type": "string",
"description": "Nome completo do aluno",
"example": "João Silva Santos"
},
"telefone": {
"type": "string",
"description": "Telefone com DDD",
"example": "(21) 99999-9999"
},
"dataNascimento": {
"type": "string",
"description": "Data de nascimento (formato ISO: YYYY-MM-DD)",
"example": "2015-03-15",
"format": "date"
},
"serie": {
"type": "string",
"description": "Série desejada",
"example": "8º ano"
},
"turno": {
"type": "string",
"description": "Turno: MANHA ou TARDE",
@ -36,11 +15,32 @@
"TARDE"
]
},
"nomeCompleto": {
"type": "string",
"description": "Nome completo do aluno",
"example": "João Silva Santos"
},
"dataNascimento": {
"type": "string",
"description": "Data de nascimento (formato ISO: YYYY-MM-DD)",
"example": "2015-03-15",
"format": "date"
},
"email": {
"type": "string",
"description": "Email para contato",
"example": "maria.santos@example.com"
},
"serie": {
"type": "string",
"description": "Série desejada",
"example": "8º ano"
},
"telefone": {
"type": "string",
"description": "Telefone com DDD",
"example": "(21) 99999-9999"
},
"endereco": {
"type": "string",
"description": "Endereço completo",

View file

@ -1,21 +1,20 @@
USE_WEBSITE("https://salesianos.br", "30d");
USE_KB "carta";
USE_KB "proc";
USE KB "carta";
USE KB "proc";
USE TOOL "inscricao";
USE TOOL "consultar_inscricao";
USE TOOL "agendamento_visita";
USE TOOL "informacoes_curso";
USE TOOL "documentos_necessarios";
USE TOOL "contato_secretaria";
USE TOOL "";
USE TOOL "calendario_letivo";
ADD_SUGGESTION_TOOL "inscricao" as "📚 Fazer Inscrição";
ADD_SUGGESTION_TOOL "consultar_inscricao" as "🔍 Consultar Inscrição";
ADD_SUGGESTION_TOOL "agendamento_visita" as "🏫 Agendar Visita";
ADD_SUGGESTION_TOOL "informacoes_curso" as "📖 Informações de Cursos";
ADD_SUGGESTION_TOOL "documentos_necessarios" as "📋 Documentos Necessários";
ADD_SUGGESTION_TOOL "contato_secretaria" as "📞 Falar com Secretaria";
ADD_SUGGESTION_TOOL "" as "Segunda Via de Boleto";
ADD_SUGGESTION_TOOL "calendario_letivo" as "📅 Calendário Letivo";
ADD_SUGGESTION_TOOL "inscricao" as "Fazer Inscrição";
ADD_SUGGESTION_TOOL "consultar_inscricao" as "Consultar Inscrição";
ADD_SUGGESTION_TOOL "agendamento_visita" as "Agendar Visita";
ADD_SUGGESTION_TOOL "informacoes_curso" as "Informações de Cursos";
ADD_SUGGESTION_TOOL "documentos_necessarios" as "Documentos Necessários";
ADD_SUGGESTION_TOOL "contato_secretaria" as "Falar com Secretaria";
ADD_SUGGESTION_TOOL "segunda_via" as "Segunda Via de Boleto";
ADD_SUGGESTION_TOOL "calendario_letivo" as "Calendário Letivo";
ADD_SUGGESTION_TOOL "outros" as "Outros";
TALK "Olá! Sou o assistente virtual da Escola Salesiana. Como posso ajudá-lo hoje com inscrições, visitas, informações sobre cursos, documentos ou calendário letivo?";

View file

@ -1,7 +1,8 @@
USE_WEBSITE("https://salesianos.br", "30d")
USE_KB "carta"
USE_KB "proc"
USE KB "carta"
USE KB "proc"
USE TOOL "inscricao"
USE TOOL "consultar_inscricao"
@ -9,17 +10,16 @@ USE TOOL "agendamento_visita"
USE TOOL "informacoes_curso"
USE TOOL "documentos_necessarios"
USE TOOL "contato_secretaria"
USE TOOL ""
USE TOOL "calendario_letivo"
ADD_SUGGESTION_TOOL "inscricao" AS "📚 Fazer Inscrição"
ADD_SUGGESTION_TOOL "consultar_inscricao" AS "🔍 Consultar Inscrição"
ADD_SUGGESTION_TOOL "agendamento_visita" AS "🏫 Agendar Visita"
ADD_SUGGESTION_TOOL "informacoes_curso" AS "📖 Informações de Cursos"
ADD_SUGGESTION_TOOL "documentos_necessarios" AS "📋 Documentos Necessários"
ADD_SUGGESTION_TOOL "contato_secretaria" AS "📞 Falar com Secretaria"
ADD_SUGGESTION_TOOL "" AS "Segunda Via de Boleto"
ADD_SUGGESTION_TOOL "calendario_letivo" AS "📅 Calendário Letivo"
ADD_SUGGESTION_TOOL "inscricao" AS "Fazer Inscrição"
ADD_SUGGESTION_TOOL "consultar_inscricao" AS "Consultar Inscrição"
ADD_SUGGESTION_TOOL "agendamento_visita" AS "Agendar Visita"
ADD_SUGGESTION_TOOL "informacoes_curso" AS "Informações de Cursos"
ADD_SUGGESTION_TOOL "documentos_necessarios" AS "Documentos Necessários"
ADD_SUGGESTION_TOOL "contato_secretaria" AS "Falar com Secretaria"
ADD_SUGGESTION_TOOL "segunda_via" AS "Segunda Via de Boleto"
ADD_SUGGESTION_TOOL "calendario_letivo" AS "Calendário Letivo"
ADD_SUGGESTION_TOOL "outros" AS "Outros"
REM Validar região para escolha de secretaria.
REM Sincronizar as bases entre o Bot e a Org.

View file

@ -1,4 +1,5 @@
pub mod editor;
pub mod database;
pub mod git;
#[cfg(feature = "terminal")]
pub mod terminal;

View file

@ -1,3 +1,4 @@
#[cfg(feature = "terminal")]
use axum::{
extract::{
Query,

View file

@ -44,7 +44,14 @@ pub fn convert_mail_line_with_substitution(line: &str) -> String {
current_var.clear();
}
_ if in_substitution => {
if c.is_alphanumeric() || c == '_' || c == '(' || c == ')' || c == ',' || c == ' ' || c == '\"' {
if c.is_alphanumeric()
|| c == '_'
|| c == '('
|| c == ')'
|| c == ','
|| c == ' '
|| c == '\"'
{
current_var.push(c);
}
}
@ -136,7 +143,10 @@ pub fn convert_mail_block(recipient: &str, lines: &[String]) -> String {
} else {
recipient.to_string()
};
result.push_str(&format!("send_mail({}, \"{}\", {}, []);\n", recipient_expr, subject, body_expr));
result.push_str(&format!(
"send_mail({}, \"{}\", {}, []);\n",
recipient_expr, subject, body_expr
));
trace!("Converted MAIL block → {}", result);
result

View file

@ -30,7 +30,10 @@ pub fn convert_begin_blocks(script: &str) -> String {
}
if upper == "END TALK" {
trace!("Converting END TALK statement, processing {} lines", talk_block_lines.len());
trace!(
"Converting END TALK statement, processing {} lines",
talk_block_lines.len()
);
in_talk_block = false;
let converted = convert_talk_block(&talk_block_lines);
result.push_str(&converted);
@ -53,7 +56,10 @@ pub fn convert_begin_blocks(script: &str) -> String {
}
if upper == "END MAIL" {
trace!("Converting END MAIL statement, processing {} lines", mail_block_lines.len());
trace!(
"Converting END MAIL statement, processing {} lines",
mail_block_lines.len()
);
in_mail_block = false;
let converted = convert_mail_block(&mail_recipient, &mail_block_lines);
result.push_str(&converted);

View file

@ -53,7 +53,14 @@ pub fn convert_talk_line_with_substitution(line: &str) -> String {
}
}
_ if in_substitution => {
if c.is_alphanumeric() || c == '_' || c == '.' || c == '[' || c == ']' || c == ',' || c == '"' {
if c.is_alphanumeric()
|| c == '_'
|| c == '.'
|| c == '['
|| c == ']'
|| c == ','
|| c == '"'
{
current_var.push(c);
} else if c == '(' {
current_var.push(c);
@ -115,12 +122,14 @@ pub fn convert_talk_line_with_substitution(line: &str) -> String {
pub fn convert_talk_block(lines: &[String]) -> String {
// Convert all lines first
let converted_lines: Vec<String> = lines.iter()
let converted_lines: Vec<String> = lines
.iter()
.map(|line| convert_talk_line_with_substitution(line))
.collect();
// Extract content after "TALK " prefix
let line_contents: Vec<String> = converted_lines.iter()
let line_contents: Vec<String> = converted_lines
.iter()
.map(|line| {
if let Some(stripped) = line.strip_prefix("TALK ") {
stripped.trim().to_string()

View file

@ -9,11 +9,11 @@ use diesel::QueryableByName;
use diesel::ExpressionMethods;
use diesel::QueryDsl;
use diesel::RunQueryDsl;
use log::{trace, warn};
use log::{info, trace, warn};
use regex::Regex;
pub mod goto_transform;
pub mod blocks;
pub mod goto_transform;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::HashSet;
@ -116,6 +116,11 @@ impl BasicCompiler {
let source_content = fs::read_to_string(source_path)
.map_err(|e| format!("Failed to read source file: {e}"))?;
// Also process tables.bas to ensure tables are created
if let Err(e) = Self::process_tables_bas(&self.state, self.bot_id) {
log::warn!("Failed to process tables.bas: {}", e);
}
if let Err(e) =
process_table_definitions(Arc::clone(&self.state), self.bot_id, &source_content)
{
@ -132,7 +137,8 @@ impl BasicCompiler {
let source_with_suggestions = self.generate_enum_suggestions(&source_content, &tool_def)?;
let ast_path = format!("{output_dir}/{file_name}.ast");
let ast_content = self.preprocess_basic(&source_with_suggestions, source_path, self.bot_id)?;
let ast_content =
self.preprocess_basic(&source_with_suggestions, source_path, self.bot_id)?;
fs::write(&ast_path, &ast_content).map_err(|e| format!("Failed to write AST file: {e}"))?;
let (mcp_json, tool_json) = if tool_def.parameters.is_empty() {
// No parameters — generate minimal mcp.json so USE TOOL can find this tool
@ -244,12 +250,7 @@ impl BasicCompiler {
// Parse the array elements
let values: Vec<String> = array_content
.split(',')
.map(|s| {
s.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
})
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect();
Some(values)
@ -470,7 +471,8 @@ impl BasicCompiler {
.ok();
}
let website_regex = Regex::new(r#"(?i)USE\s+WEBSITE\s+"([^"]+)"(?:\s+REFRESH\s+"([^"]+)")?"#)?;
let website_regex =
Regex::new(r#"(?i)USE\s+WEBSITE\s+"([^"]+)"(?:\s+REFRESH\s+"([^"]+)")?"#)?;
for line in source.lines() {
let trimmed = line.trim();
@ -498,7 +500,8 @@ impl BasicCompiler {
.conn
.get()
.map_err(|e| format!("Failed to get database connection: {e}"))?;
if let Err(e) = execute_set_schedule(&mut conn, cron, &script_name, bot_id) {
if let Err(e) = execute_set_schedule(&mut conn, cron, &script_name, bot_id)
{
log::error!(
"Failed to schedule SET SCHEDULE during preprocessing: {}",
e
@ -682,7 +685,11 @@ impl BasicCompiler {
let table_name = table_name.trim_matches('"');
// Debug log to see what we're querying
log::trace!("Converting SAVE for table: '{}' (original: '{}')", table_name, &parts[0]);
log::trace!(
"Converting SAVE for table: '{}' (original: '{}')",
table_name,
&parts[0]
);
// Get column names from TABLE definition (preserves order from .bas file)
let column_names = self.get_table_columns_for_save(table_name, bot_id)?;
@ -691,13 +698,20 @@ impl BasicCompiler {
let values: Vec<&String> = parts.iter().skip(1).collect();
let mut map_pairs = Vec::new();
log::trace!("Matching {} variables to {} columns", values.len(), column_names.len());
log::trace!(
"Matching {} variables to {} columns",
values.len(),
column_names.len()
);
for value_var in values.iter() {
// Find the column that matches this variable (case-insensitive)
let value_lower = value_var.to_lowercase();
if let Some(column_name) = column_names.iter().find(|col| col.to_lowercase() == value_lower) {
if let Some(column_name) = column_names
.iter()
.find(|col| col.to_lowercase() == value_lower)
{
map_pairs.push(format!("{}: {}", column_name, value_var));
} else {
log::warn!("No matching column for variable '{}'", value_var);
@ -709,13 +723,19 @@ impl BasicCompiler {
*save_counter += 1;
// Generate: let __save_data_N__ = #{...}; SAVE "table", __save_data_N__
let converted = format!("let {} = {}; SAVE {}, {}", data_var, map_expr, table_name, data_var);
let converted = format!(
"let {} = {}; SAVE {}, {}",
data_var, map_expr, table_name, data_var
);
Ok(Some(converted))
}
/// Parse SAVE statement into parts
fn parse_save_statement(&self, content: &str) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
fn parse_save_statement(
&self,
content: &str,
) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
// Simple parsing - split by comma, but respect quoted strings
let mut parts = Vec::new();
let mut current = String::new();
@ -759,7 +779,11 @@ impl BasicCompiler {
// Try to parse TABLE definition from the bot's .bas files to get correct field order
if let Ok(columns) = self.get_columns_from_table_definition(table_name, bot_id) {
if !columns.is_empty() {
log::trace!("Using TABLE definition for '{}': {} columns", table_name, columns.len());
log::trace!(
"Using TABLE definition for '{}': {} columns",
table_name,
columns.len()
);
return Ok(columns);
}
}
@ -778,7 +802,10 @@ impl BasicCompiler {
// Find the tables.bas file in the bot's data directory
let bot_name = self.get_bot_name_by_id(bot_id)?;
let tables_path = format!("/opt/gbo/data/{}.gbai/{}.gbdialog/tables.bas", bot_name, bot_name);
let tables_path = format!(
"/opt/gbo/data/{}.gbai/{}.gbdialog/tables.bas",
bot_name, bot_name
);
let tables_content = fs::read_to_string(&tables_path)?;
let columns = self.parse_table_definition_for_fields(&tables_content, table_name)?;
@ -822,12 +849,63 @@ impl BasicCompiler {
Ok(columns)
}
/// Process tables.bas file to ensure all tables are created
pub fn process_tables_bas(
state: &Arc<AppState>,
bot_id: uuid::Uuid,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let bot_name = Self::get_bot_name_from_state(state, bot_id)?;
let tables_path = format!(
"/opt/gbo/data/{}.gbai/{}.gbdialog/tables.bas",
bot_name, bot_name
);
if !Path::new(&tables_path).exists() {
trace!("tables.bas not found for bot {}, skipping", bot_name);
return Ok(());
}
let tables_content = fs::read_to_string(&tables_path)?;
trace!(
"Processing tables.bas for bot {}: {}",
bot_name,
tables_path
);
// This will create/sync all tables defined in tables.bas
process_table_definitions(Arc::clone(state), bot_id, &tables_content)?;
info!("Successfully processed tables.bas for bot {}", bot_name);
Ok(())
}
/// Get bot name from state using bot_id
fn get_bot_name_from_state(
state: &Arc<AppState>,
bot_id: uuid::Uuid,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut conn = state.conn.get()?;
use crate::core::shared::models::schema::bots::dsl::*;
bots.filter(id.eq(bot_id))
.select(name)
.first::<String>(&mut *conn)
.map_err(|e| format!("Failed to get bot name: {}", e).into())
}
/// Get bot name by bot_id
fn get_bot_name_by_id(&self, bot_id: uuid::Uuid) -> Result<String, Box<dyn Error + Send + Sync>> {
fn get_bot_name_by_id(
&self,
bot_id: uuid::Uuid,
) -> Result<String, Box<dyn Error + Send + Sync>> {
use crate::core::shared::models::schema::bots::dsl::*;
use diesel::QueryDsl;
let mut conn = self.state.conn.get()
let mut conn = self
.state
.conn
.get()
.map_err(|e| format!("Failed to get DB connection: {}", e))?;
let bot_name: String = bots
@ -857,7 +935,10 @@ impl BasicCompiler {
// First, try to get columns from the main database's information_schema
// This works because tables are created in the bot's database which shares the schema
let mut conn = self.state.conn.get()
let mut conn = self
.state
.conn
.get()
.map_err(|e| format!("Failed to get DB connection: {}", e))?;
let query = format!(
@ -870,12 +951,15 @@ impl BasicCompiler {
let columns: Vec<String> = match sql_query(&query).load(&mut conn) {
Ok(cols) => {
if cols.is_empty() {
log::warn!("Found 0 columns for table '{}' in main database, trying bot database", table_name);
log::warn!(
"Found 0 columns for table '{}' in main database, trying bot database",
table_name
);
// Try bot's database as fallback when main DB returns empty
let bot_pool = self.state.bot_database_manager.get_bot_pool(bot_id);
if let Ok(pool) = bot_pool {
let mut bot_conn = pool.get()
.map_err(|e| format!("Bot DB error: {}", e))?;
let mut bot_conn =
pool.get().map_err(|e| format!("Bot DB error: {}", e))?;
let bot_query = format!(
"SELECT column_name FROM information_schema.columns \
@ -886,13 +970,22 @@ impl BasicCompiler {
match sql_query(&bot_query).load(&mut *bot_conn) {
Ok(bot_cols) => {
log::trace!("Found {} columns for table '{}' in bot database", bot_cols.len(), table_name);
bot_cols.into_iter()
log::trace!(
"Found {} columns for table '{}' in bot database",
bot_cols.len(),
table_name
);
bot_cols
.into_iter()
.map(|c: ColumnRow| c.column_name)
.collect()
}
Err(e) => {
log::error!("Failed to get columns from bot DB for '{}': {}", table_name, e);
log::error!(
"Failed to get columns from bot DB for '{}': {}",
table_name,
e
);
Vec::new()
}
}
@ -901,20 +994,25 @@ impl BasicCompiler {
Vec::new()
}
} else {
log::trace!("Found {} columns for table '{}' in main database", cols.len(), table_name);
cols.into_iter()
.map(|c: ColumnRow| c.column_name)
.collect()
log::trace!(
"Found {} columns for table '{}' in main database",
cols.len(),
table_name
);
cols.into_iter().map(|c: ColumnRow| c.column_name).collect()
}
}
Err(e) => {
log::warn!("Failed to get columns for table '{}' from main DB: {}", table_name, e);
log::warn!(
"Failed to get columns for table '{}' from main DB: {}",
table_name,
e
);
// Try bot's database as fallback
let bot_pool = self.state.bot_database_manager.get_bot_pool(bot_id);
if let Ok(pool) = bot_pool {
let mut bot_conn = pool.get()
.map_err(|e| format!("Bot DB error: {}", e))?;
let mut bot_conn = pool.get().map_err(|e| format!("Bot DB error: {}", e))?;
let bot_query = format!(
"SELECT column_name FROM information_schema.columns \
@ -925,14 +1023,22 @@ impl BasicCompiler {
match sql_query(&bot_query).load(&mut *bot_conn) {
Ok(cols) => {
log::trace!("Found {} columns for table '{}' in bot database", cols.len(), table_name);
log::trace!(
"Found {} columns for table '{}' in bot database",
cols.len(),
table_name
);
cols.into_iter()
.filter(|c: &ColumnRow| c.column_name != "id")
.map(|c: ColumnRow| c.column_name)
.collect()
}
Err(e) => {
log::error!("Failed to get columns from bot DB for '{}': {}", table_name, e);
log::error!(
"Failed to get columns from bot DB for '{}': {}",
table_name,
e
);
Vec::new()
}
}

View file

@ -2,10 +2,11 @@ use crate::core::shared::models::UserSession;
use crate::core::shared::state::AppState;
use diesel::prelude::*;
use diesel::sql_types::*;
use log::error;
use log::{error, trace};
use rhai::{Dynamic, Engine};
use serde_json::Value;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, QueryableByName)]
struct ColumnRow {
@ -13,8 +14,9 @@ struct ColumnRow {
column_name: String,
}
pub fn detect_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
pub fn detect_keyword(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
let state_clone = Arc::clone(&state);
let bot_id = user.bot_id;
engine
.register_custom_syntax(["DETECT", "$expr$"], false, move |context, inputs| {
@ -27,6 +29,7 @@ pub fn detect_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Eng
let table_name = context.eval_expression_tree(first_input)?.to_string();
let state_for_thread = Arc::clone(&state_clone);
let bot_id_for_thread = bot_id;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
@ -37,7 +40,7 @@ pub fn detect_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Eng
let send_err = if let Ok(rt) = rt {
let result = rt.block_on(async move {
detect_anomalies_in_table(state_for_thread, &table_name).await
detect_anomalies_in_table(state_for_thread, &table_name, bot_id_for_thread).await
});
tx.send(result).err()
} else {
@ -73,10 +76,13 @@ pub fn detect_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Eng
async fn detect_anomalies_in_table(
state: Arc<AppState>,
table_name: &str,
bot_id: Uuid,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let columns = get_table_columns(&state, table_name)?;
let columns = get_table_columns(&state, table_name, bot_id)?;
let value_field = find_numeric_field(&columns);
trace!("DETECT: columns = {:?}, value_field = {}", columns, value_field);
#[derive(QueryableByName)]
struct JsonRow {
#[diesel(sql_type = Text)]
@ -89,8 +95,9 @@ async fn detect_anomalies_in_table(
column_list, table_name
);
let pool = state.bot_database_manager.get_bot_pool(bot_id)?;
let rows: Vec<JsonRow> = diesel::sql_query(&query)
.load(&mut state.conn.get()?)?;
.load(&mut pool.get()?)?;
let records: Vec<Value> = rows
.into_iter()
@ -108,7 +115,7 @@ async fn detect_anomalies_in_table(
let client = reqwest::Client::new();
let response = client
.post(format!("{}/api/anomaly/detect", botmodels_host))
.post(format!("{}/api/detect", botmodels_host))
.header("X-API-Key", &botmodels_key)
.json(&serde_json::json!({
"data": records,
@ -129,14 +136,16 @@ async fn detect_anomalies_in_table(
fn get_table_columns(
state: &Arc<AppState>,
table_name: &str,
bot_id: Uuid,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let query = format!(
"SELECT column_name FROM information_schema.columns WHERE table_name = '{}' ORDER BY ordinal_position",
table_name
);
let pool = state.bot_database_manager.get_bot_pool(bot_id)?;
let rows: Vec<ColumnRow> = diesel::sql_query(&query)
.load(&mut state.conn.get()?)?;
.load(&mut pool.get()?)?;
Ok(rows.into_iter().map(|r| r.column_name).collect())
}
@ -144,7 +153,8 @@ fn get_table_columns(
fn find_numeric_field(columns: &[String]) -> String {
let numeric_keywords = ["salario", "salary", "valor", "value", "amount", "preco", "price",
"temperatura", "temp", "pressao", "pressure", "quantidade", "quantity",
"decimal", "numerico", "numeric", "base", "liquido", "bruto"];
"decimal", "numerico", "numeric", "base", "liquido", "bruto",
"desconto", "vantagem", "gratificacao"];
for col in columns {
let col_lower = col.to_lowercase();
@ -155,5 +165,17 @@ fn find_numeric_field(columns: &[String]) -> String {
}
}
columns.first().cloned().unwrap_or_else(|| "value".to_string())
let skip_keywords = ["id", "nome", "cpf", "matricula", "orgao", "cargo", "nivel",
"mes", "ano", "tipo", "categoria", "data", "status", "email",
"telefone", "protocolo", "servidor"];
for col in columns {
let col_lower = col.to_lowercase();
let is_string = skip_keywords.iter().any(|kw| col_lower.contains(kw));
if !is_string {
return col.clone();
}
}
columns.last().cloned().unwrap_or_else(|| "value".to_string())
}

View file

@ -7,94 +7,58 @@ use rhai::Engine;
#[cfg(feature = "llm")]
use rhai::{Dynamic, Engine};
use std::sync::Arc;
use std::time::Duration;
#[cfg(feature = "llm")]
pub fn register_enhanced_llm_keyword(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
let state_clone1 = Arc::clone(&state);
let state_clone2 = Arc::clone(&state);
let user_clone = user;
pub fn register_enhanced_llm_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
let state_clone = Arc::clone(&state);
if let Err(e) = engine.register_custom_syntax(
["LLM", "$string$", "WITH", "OPTIMIZE", "FOR", "$string$"],
["LLM", "$string$"],
false,
move |context, inputs| {
let prompt = context.eval_expression_tree(&inputs[0])?.to_string();
let optimization = context.eval_expression_tree(&inputs[1])?.to_string();
let state_for_thread = Arc::clone(&state_clone);
let (tx, rx) = std::sync::mpsc::channel();
let state_for_spawn = Arc::clone(&state_clone1);
let _user_clone_spawn = user_clone.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build();
tokio::spawn(async move {
let router = SmartLLMRouter::new(state_for_spawn);
let goal = OptimizationGoal::from_str_name(&optimization);
match crate::llm::smart_router::enhanced_llm_call(
&router, &prompt, goal, None, None,
)
.await
{
Ok(_response) => {
log::info!("LLM response generated with {} optimization", optimization);
}
Err(e) => {
log::error!("Enhanced LLM call failed: {}", e);
}
if let Ok(rt) = rt {
let result = rt.block_on(async move {
let router = SmartLLMRouter::new(Arc::clone(&state_for_thread));
crate::llm::smart_router::enhanced_llm_call(
&state_for_thread, &router, &prompt, OptimizationGoal::Balanced, None, None,
)
.await
});
let _ = tx.send(result);
}
});
Ok(Dynamic::from("LLM response"))
},
) {
log::warn!("Failed to register enhanced LLM syntax: {e}");
}
if let Err(e) = engine.register_custom_syntax(
[
"LLM",
"$string$",
"WITH",
"MAX_COST",
"$float$",
"MAX_LATENCY",
"$int$",
],
false,
move |context, inputs| {
let prompt = context.eval_expression_tree(&inputs[0])?.to_string();
let max_cost = context.eval_expression_tree(&inputs[1])?.as_float()?;
let max_latency = context.eval_expression_tree(&inputs[2])?.as_int()? as u64;
let state_for_spawn = Arc::clone(&state_clone2);
tokio::spawn(async move {
let router = SmartLLMRouter::new(state_for_spawn);
match crate::llm::smart_router::enhanced_llm_call(
&router,
&prompt,
OptimizationGoal::Balanced,
Some(max_cost),
Some(max_latency),
)
.await
{
Ok(_response) => {
log::info!(
"LLM response with constraints: cost<={}, latency<={}",
max_cost,
max_latency
);
}
Err(e) => {
log::error!("Constrained LLM call failed: {}", e);
}
match rx.recv_timeout(Duration::from_secs(60)) {
Ok(Ok(response)) => Ok(Dynamic::from(response)),
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
e.to_string().into(),
rhai::Position::NONE,
))),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
"LLM generation timed out".into(),
rhai::Position::NONE,
)))
}
});
Ok(Dynamic::from("LLM response"))
Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
format!("LLM thread failed: {e}").into(),
rhai::Position::NONE,
))),
}
},
) {
log::warn!("Failed to register constrained LLM syntax: {e}");
log::warn!("Failed to register simple LLM syntax: {e}");
}
}

View file

@ -325,6 +325,10 @@ impl ScriptService {
let _ = self; // silence unused self warning - kept for API consistency
let script = preprocess_switch(script);
// Preprocess LLM keyword to add WITH OPTIMIZE FOR "speed" syntax
// This is needed because Rhai's custom syntax requires the full syntax
let script = Self::preprocess_llm_keyword(&script);
// Convert ALL multi-word keywords to underscore versions (e.g., "USE WEBSITE" → "USE_WEBSITE")
// This avoids Rhai custom syntax conflicts and makes the system more secure
let script = Self::convert_multiword_keywords(&script);
@ -2051,6 +2055,59 @@ impl ScriptService {
word.to_lowercase()
}
}
fn preprocess_llm_keyword(script: &str) -> String {
// Transform LLM "prompt" to LLM "prompt" WITH OPTIMIZE FOR "speed"
// Handle cases like:
// LLM "text"
// LLM "text" + var
// result = LLM "text"
// result = LLM "text" + var
let mut result = String::new();
let chars: Vec<char> = script.chars().collect();
let mut i = 0;
while i < chars.len() {
// Check for LLM keyword (case insensitive)
let remaining: String = chars[i..].iter().collect();
let remaining_upper = remaining.to_uppercase();
if remaining_upper.starts_with("LLM ") {
// Found LLM - copy "LLM " and find the quoted string
result.push_str("LLM ");
i += 4;
// Now find the quoted string
if i < chars.len() && chars[i] == '"' {
result.push('"');
i += 1;
// Copy quoted string
while i < chars.len() && chars[i] != '"' {
result.push(chars[i]);
i += 1;
}
if i < chars.len() && chars[i] == '"' {
result.push('"');
i += 1;
}
// Add WITH OPTIMIZE FOR "speed" if not present
let before_with = result.trim_end_matches('"');
if !before_with.to_uppercase().contains("WITH OPTIMIZE") {
result = format!("{} WITH OPTIMIZE FOR \"speed\"", before_with);
}
// Continue copying rest of line in outer loop (don't break)
}
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
}

View file

@ -186,10 +186,13 @@ impl BootstrapManager {
let config_path = self.stack_dir("conf/system/directory_config.json");
if !config_path.exists() {
info!("Creating OAuth client for Directory service...");
#[cfg(feature = "directory")]
match crate::core::package_manager::setup_directory().await {
Ok(_) => info!("OAuth client created successfully"),
Err(e) => warn!("Failed to create OAuth client: {}", e),
}
#[cfg(not(feature = "directory"))]
info!("Directory feature not enabled, skipping OAuth setup");
} else {
info!("Directory config already exists, skipping OAuth setup");
}
@ -218,10 +221,13 @@ impl BootstrapManager {
let config_path = self.stack_dir("conf/system/directory_config.json");
if !config_path.exists() {
info!("Creating OAuth client for Directory service...");
#[cfg(feature = "directory")]
match crate::core::package_manager::setup_directory().await {
Ok(_) => info!("OAuth client created successfully"),
Err(e) => warn!("Failed to create OAuth client: {}", e),
}
#[cfg(not(feature = "directory"))]
info!("Directory feature not enabled, skipping OAuth setup");
}
}
}

View file

@ -1,5 +1,7 @@
// Email invitation functions
#[cfg(feature = "mail")]
use log::warn;
#[cfg(feature = "mail")]
use uuid::Uuid;
#[cfg(feature = "mail")]
use lettre::{

View file

@ -1,4 +1,3 @@
pub mod core;
pub use self::core::*;
@ -17,11 +16,11 @@ pub use super::schema;
// Re-export core schema tables
pub use super::schema::{
basic_tools, bot_configuration, bot_memories, bots, clicks,
message_history, organizations, rbac_group_roles, rbac_groups,
rbac_permissions, rbac_role_permissions, rbac_roles, rbac_user_groups, rbac_user_roles,
session_tool_associations, system_automations, user_login_tokens,
user_preferences, user_sessions, users, workflow_executions, workflow_events, bot_shared_memory,
basic_tools, bot_configuration, bot_memories, bot_shared_memory, bots, clicks, message_history,
organizations, rbac_group_roles, rbac_groups, rbac_permissions, rbac_role_permissions,
rbac_roles, rbac_user_groups, rbac_user_roles, session_tool_associations, system_automations,
user_login_tokens, user_preferences, user_sessions, users, workflow_events,
workflow_executions,
};
// Re-export feature-gated schema tables
@ -31,28 +30,23 @@ pub use super::schema::tasks;
#[cfg(feature = "mail")]
pub use super::schema::{
distribution_lists, email_auto_responders, email_drafts, email_folders,
email_label_assignments, email_labels, email_rules, email_signatures,
email_templates, global_email_signatures, scheduled_emails,
shared_mailbox_members, shared_mailboxes, user_email_accounts,
email_label_assignments, email_labels, email_rules, email_signatures, email_templates,
global_email_signatures, scheduled_emails, shared_mailbox_members, shared_mailboxes,
user_email_accounts,
};
#[cfg(feature = "people")]
pub use super::schema::{
crm_accounts, crm_activities, crm_contacts, crm_leads, crm_notes,
crm_opportunities, crm_pipeline_stages, people, people_departments,
people_org_chart, people_person_skills, people_skills, people_team_members,
people_teams, people_time_off,
};
#[cfg(feature = "vectordb")]
pub use super::schema::{
kb_collections, kb_documents, kb_group_associations, user_kb_associations,
crm_accounts, crm_activities, crm_contacts, crm_leads, crm_notes, crm_opportunities,
crm_pipeline_stages, people, people_departments, people_org_chart, people_person_skills,
people_skills, people_team_members, people_teams, people_time_off,
};
#[cfg(feature = "rbac")]
pub use super::schema::kb_group_associations;
pub use botlib::message_types::MessageType;
pub use botlib::models::{ApiResponse, Attachment, BotResponse, Session, Suggestion, UserMessage};
// Manually export OrganizationInvitation as it is defined in core but table is organization_invitations
pub use self::core::OrganizationInvitation;

View file

@ -1,4 +1,6 @@
use crate::core::shared::state::AppState;
use crate::llm::OpenAIClient;
use crate::core::config::ConfigManager;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
@ -144,6 +146,7 @@ impl SmartLLMRouter {
// Enhanced LLM keyword with optimization
pub async fn enhanced_llm_call(
state: &Arc<AppState>,
router: &SmartLLMRouter,
prompt: &str,
optimization_goal: OptimizationGoal,
@ -152,17 +155,37 @@ pub async fn enhanced_llm_call(
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let start_time = Instant::now();
// Select optimal model
// Select optimal model (for tracking)
let model = router
.select_optimal_model("general", optimization_goal, max_cost, max_latency)
.await?;
// Make LLM call (simplified - would use actual LLM provider)
let response = format!("Response from {} for: {}", model, prompt);
// Get actual LLM configuration from bot's config
let config_manager = ConfigManager::new(state.conn.clone());
let actual_model = config_manager
.get_config(&uuid::Uuid::nil(), "llm-model", None)
.unwrap_or_else(|_| model.clone());
let key = config_manager
.get_config(&uuid::Uuid::nil(), "llm-key", None)
.unwrap_or_else(|_| String::new());
// Build messages for LLM call
let messages = OpenAIClient::build_messages(
"Você é um assistente útil que resume dados em português.",
"",
&[("user".to_string(), prompt.to_string())],
);
// Make actual LLM call
let response = state
.llm_provider
.generate(prompt, &messages, &actual_model, &key)
.await
.map_err(|e| format!("LLM error: {}", e))?;
// Track performance
let latency = start_time.elapsed().as_millis() as u64;
let cost_per_token = match model.as_str() {
let cost_per_token = match actual_model.as_str() {
"gpt-4" => 0.03,
"gpt-4o-mini" => 0.0015,
"claude-3-sonnet" => 0.015,
@ -170,7 +193,7 @@ pub async fn enhanced_llm_call(
};
router
.track_performance(&model, latency, cost_per_token, true)
.track_performance(&actual_model, latency, cost_per_token, true)
.await?;
Ok(response)

View file

@ -187,6 +187,72 @@ pub async fn run_axum_server(
.nest(ApiUrls::AUTH, crate::directory::auth_routes::configure());
}
#[cfg(not(feature = "directory"))]
{
use axum::extract::State;
use axum::response::IntoResponse;
use std::collections::HashMap;
async fn anonymous_auth_handler(
State(state): State<Arc<AppState>>,
axum::extract::Query(params): axum::extract::Query<HashMap<String, String>>,
) -> impl IntoResponse {
let bot_name = params.get("bot_name").cloned().unwrap_or_default();
let existing_session_id = params.get("session_id").cloned();
let existing_user_id = params.get("user_id").cloned();
let user_id = existing_user_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let session_id = existing_session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
// Create session in DB if it doesn't exist
let session_uuid = match uuid::Uuid::parse_str(&session_id) {
Ok(uuid) => uuid,
Err(_) => uuid::Uuid::new_v4(),
};
let user_uuid = match uuid::Uuid::parse_str(&user_id) {
Ok(uuid) => uuid,
Err(_) => uuid::Uuid::new_v4(),
};
// Get bot_id from bot_name
let bot_id = {
let conn = state.conn.get().ok();
if let Some(mut db_conn) = conn {
use crate::core::shared::models::schema::bots::dsl::*;
use diesel::prelude::*;
bots.filter(name.eq(&bot_name))
.select(id)
.first::<uuid::Uuid>(&mut db_conn)
.ok()
.unwrap_or_else(uuid::Uuid::nil)
} else {
uuid::Uuid::nil()
}
};
// Create session if it doesn't exist
let _ = {
let mut sm = state.session_manager.lock().await;
sm.get_or_create_anonymous_user(Some(user_uuid)).ok();
sm.create_session(user_uuid, bot_id, "Anonymous Chat").ok()
};
info!("Anonymous auth for bot: {}, session: {}", bot_name, session_id);
(
axum::http::StatusCode::OK,
Json(serde_json::json!({
"user_id": user_id,
"session_id": session_id,
"bot_name": bot_name,
"status": "anonymous"
})),
)
}
api_router = api_router.route(ApiUrls::AUTH, get(anonymous_auth_handler));
}
#[cfg(feature = "meet")]
{
api_router = api_router.merge(crate::meet::configure());
@ -395,8 +461,11 @@ pub async fn run_axum_server(
api_router = api_router.merge(crate::api::editor::configure_editor_routes());
api_router = api_router.merge(crate::api::database::configure_database_routes());
api_router = api_router.merge(crate::api::git::configure_git_routes());
api_router = api_router.merge(crate::api::terminal::configure_terminal_routes());
api_router = api_router.merge(crate::browser::api::configure_browser_routes());
#[cfg(feature = "terminal")]
{
api_router = api_router.merge(crate::api::terminal::configure_terminal_routes());
}
let site_path = app_state
.config

View file

@ -2,6 +2,8 @@ pub mod audit_log;
pub mod menu_config;
pub mod permission_inheritance;
pub mod rbac;
#[cfg(feature = "rbac")]
pub mod rbac_kb;
pub mod rbac_ui;
pub mod security_admin;
@ -223,12 +225,17 @@ log::info!("Testing SMTP connection to {}:{}", config.host, config.port);
let mailer_result = if let (Some(user), Some(pass)) = (config.username, config.password) {
let creds = Credentials::new(user, pass);
SmtpTransport::relay(&config.host)
.map(|b| b.port(config.port as u16).credentials(creds).build())
if config.port == 465 {
SmtpTransport::relay(&config.host)
.map(|b| b.port(config.port as u16).credentials(creds).build())
} else {
SmtpTransport::starttls_relay(&config.host)
.map(|b| b.port(config.port as u16).credentials(creds).build())
}
} else {
Ok(SmtpTransport::builder_dangerous(&config.host)
SmtpTransport::builder_dangerous(&config.host)
.port(config.port as u16)
.build())
.build()
};
match mailer_result {
@ -323,8 +330,9 @@ r##"<div class="success-message">
async fn get_trusted_devices(State(_state): State<Arc<AppState>>) -> Html<String> {
Html(
r##"<div class="device-item current">
r####"<div class="device-item current">
<div class="device-info">
"##
<span class="device-icon">💻</span>
<div class="device-details">
<span class="device-name">Current Device</span>
@ -333,4 +341,4 @@ r##"<div class="device-item current">
</div>
<span class="device-badge trusted">Trusted</span>
</div> <div class="devices-empty"> <p class="text-muted">No other trusted devices</p> </div>"## .to_string(), ) }
</div> <div class="devices-empty"> <p class="text-muted">No other trusted devices</p> </div>"#### .to_string(), ) }

View file

@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
#[cfg(feature = "rbac")]
use crate::settings::rbac_kb::{
assign_kb_to_group, get_accessible_kbs_for_user, get_kb_groups, remove_kb_from_group,
};
@ -38,10 +39,6 @@ pub fn configure_rbac_routes() -> Router<Arc<AppState>> {
.route("/api/rbac/groups/{group_id}/roles", get(get_group_roles))
.route("/api/rbac/groups/{group_id}/roles/{role_id}", post(assign_role_to_group).delete(remove_role_from_group))
.route("/api/rbac/users/{user_id}/permissions", get(get_effective_permissions))
// KB-group management
.route("/api/rbac/kbs/{kb_id}/groups", get(get_kb_groups))
.route("/api/rbac/kbs/{kb_id}/groups/{group_id}", post(assign_kb_to_group).delete(remove_kb_from_group))
.route("/api/rbac/users/{user_id}/accessible-kbs", get(get_accessible_kbs_for_user))
.route("/settings/rbac", get(rbac_settings_page))
.route("/settings/rbac/users", get(rbac_users_list))
.route("/settings/rbac/roles", get(rbac_roles_list))