botserver/src/basic/keywords/human_approval.rs
Rodrigo Rodriguez (Pragmatismo) 5da86bbef2 Fix clippy warnings: match arms, async/await, Debug impls, formatting
- Fix match arms with identical bodies by consolidating patterns
- Fix case-insensitive file extension comparisons using eq_ignore_ascii_case
- Fix unnecessary Debug formatting in log/format macros
- Fix clone_from usage instead of clone assignment
- Fix let...else patterns where appropriate
- Fix format! append to String using write! macro
- Fix unwrap_or with function calls to use unwrap_or_else
- Add missing fields to manual Debug implementations
- Fix duplicate code in if blocks
- Add type aliases for complex types
- Rename struct fields to avoid common prefixes
- Various other clippy warning fixes

Note: Some 'unused async' warnings remain for functions that are
called with .await but don't contain await internally - these are
kept async for API compatibility.
2025-12-26 08:59:25 -03:00

774 lines
22 KiB
Rust

use chrono::{DateTime, Duration, Utc};
use rhai::{Array, Dynamic, Engine, Map};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::info;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRequest {
pub id: Uuid,
pub bot_id: Uuid,
pub session_id: Uuid,
pub initiated_by: Uuid,
pub approval_type: String,
pub status: ApprovalStatus,
pub channel: ApprovalChannel,
pub recipient: String,
pub context: serde_json::Value,
pub message: String,
pub timeout_seconds: u64,
pub default_action: Option<ApprovalDecision>,
pub current_level: u32,
pub total_levels: u32,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub reminders_sent: Vec<DateTime<Utc>>,
pub decision: Option<ApprovalDecision>,
pub decided_by: Option<String>,
pub decided_at: Option<DateTime<Utc>>,
pub comments: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalStatus {
Pending,
Approved,
Rejected,
TimedOut,
Cancelled,
Escalated,
Error,
}
impl Default for ApprovalStatus {
fn default() -> Self {
Self::Pending
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalDecision {
Approve,
Reject,
Escalate,
Defer,
RequestInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalChannel {
Email,
Sms,
Mobile,
Teams,
Slack,
Webhook,
InApp,
}
impl Default for ApprovalChannel {
fn default() -> Self {
Self::Email
}
}
impl std::fmt::Display for ApprovalChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Email => write!(f, "email"),
Self::Sms => write!(f, "sms"),
Self::Mobile => write!(f, "mobile"),
Self::Teams => write!(f, "teams"),
Self::Slack => write!(f, "slack"),
Self::Webhook => write!(f, "webhook"),
Self::InApp => write!(f, "in_app"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalChain {
pub name: String,
pub bot_id: Uuid,
pub levels: Vec<ApprovalLevel>,
pub stop_on_reject: bool,
pub require_all: bool,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalLevel {
pub level: u32,
pub channel: ApprovalChannel,
pub recipient: String,
pub timeout_seconds: u64,
pub condition: Option<String>,
pub skippable: bool,
pub approvers: Vec<String>,
pub required_approvals: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalAuditEntry {
pub id: Uuid,
pub request_id: Uuid,
pub action: AuditAction,
pub actor: String,
pub details: serde_json::Value,
pub timestamp: DateTime<Utc>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
RequestCreated,
NotificationSent,
ReminderSent,
Approved,
Rejected,
Escalated,
TimedOut,
Cancelled,
CommentAdded,
ContextUpdated,
}
#[derive(Debug, Clone)]
pub struct ApprovalConfig {
pub enabled: bool,
pub default_timeout: u64,
pub reminder_interval: u64,
pub max_reminders: u32,
pub audit_enabled: bool,
pub webhook_url: Option<String>,
pub email_template: Option<String>,
pub approval_base_url: Option<String>,
}
impl Default for ApprovalConfig {
fn default() -> Self {
Self {
enabled: true,
default_timeout: 3600,
reminder_interval: 1800,
max_reminders: 3,
audit_enabled: true,
webhook_url: None,
email_template: None,
approval_base_url: None,
}
}
}
#[derive(Debug)]
pub struct ApprovalManager {
config: ApprovalConfig,
}
impl ApprovalManager {
pub fn new(config: ApprovalConfig) -> Self {
Self { config }
}
pub fn from_config(config_map: &HashMap<String, String>) -> Self {
let config = ApprovalConfig {
enabled: config_map
.get("approval-enabled")
.map(|v| v == "true")
.unwrap_or(true),
default_timeout: config_map
.get("approval-default-timeout")
.and_then(|v| v.parse().ok())
.unwrap_or(3600),
reminder_interval: config_map
.get("approval-reminder-interval")
.and_then(|v| v.parse().ok())
.unwrap_or(1800),
max_reminders: config_map
.get("approval-max-reminders")
.and_then(|v| v.parse().ok())
.unwrap_or(3),
audit_enabled: config_map
.get("approval-audit-enabled")
.map(|v| v == "true")
.unwrap_or(true),
webhook_url: config_map.get("approval-webhook-url").cloned(),
email_template: config_map.get("approval-email-template").cloned(),
approval_base_url: config_map.get("approval-base-url").cloned(),
};
Self::new(config)
}
pub fn create_request(
&self,
bot_id: Uuid,
session_id: Uuid,
initiated_by: Uuid,
approval_type: &str,
channel: ApprovalChannel,
recipient: &str,
context: serde_json::Value,
message: &str,
timeout_seconds: Option<u64>,
default_action: Option<ApprovalDecision>,
) -> ApprovalRequest {
let timeout = timeout_seconds.unwrap_or(self.config.default_timeout);
let now = Utc::now();
ApprovalRequest {
id: Uuid::new_v4(),
bot_id,
session_id,
initiated_by,
approval_type: approval_type.to_string(),
status: ApprovalStatus::Pending,
channel,
recipient: recipient.to_string(),
context,
message: message.to_string(),
timeout_seconds: timeout,
default_action,
current_level: 1,
total_levels: 1,
created_at: now,
expires_at: now + Duration::seconds(timeout as i64),
reminders_sent: Vec::new(),
decision: None,
decided_by: None,
decided_at: None,
comments: None,
}
}
pub fn is_expired(&self, request: &ApprovalRequest) -> bool {
Utc::now() > request.expires_at
}
pub fn should_send_reminder(&self, request: &ApprovalRequest) -> bool {
if request.status != ApprovalStatus::Pending {
return false;
}
if request.reminders_sent.len() >= self.config.max_reminders as usize {
return false;
}
let last_notification = request
.reminders_sent
.last()
.copied()
.unwrap_or(request.created_at);
let since_last = Utc::now() - last_notification;
since_last.num_seconds() >= self.config.reminder_interval as i64
}
pub fn generate_approval_url(&self, request_id: Uuid, action: &str, token: &str) -> String {
let base_url = self
.config
.approval_base_url
.as_deref()
.unwrap_or("https://bot.example.com/approve");
format!(
"{}/{}?action={}&token={}",
base_url, request_id, action, token
)
}
pub fn generate_email_content(&self, request: &ApprovalRequest, token: &str) -> EmailContent {
let approve_url = self.generate_approval_url(request.id, "approve", token);
let reject_url = self.generate_approval_url(request.id, "reject", token);
let subject = format!(
"Approval Required: {} ({})",
request.approval_type, request.id
);
let body = format!(
r"
An approval is requested for:
Type: {}
Message: {}
Context:
{}
This request will expire at: {}
To approve, click: {}
To reject, click: {}
If you have questions, reply to this email.
",
request.approval_type,
request.message,
serde_json::to_string_pretty(&request.context).unwrap_or_default(),
request.expires_at.format("%Y-%m-%d %H:%M:%S UTC"),
approve_url,
reject_url
);
EmailContent {
subject,
body,
html_body: None,
}
}
pub fn process_decision(
&self,
request: &mut ApprovalRequest,
decision: ApprovalDecision,
decided_by: &str,
comments: Option<String>,
) {
request.decision = Some(decision.clone());
request.decided_by = Some(decided_by.to_string());
request.decided_at = Some(Utc::now());
request.comments = comments;
request.status = match decision {
ApprovalDecision::Approve => ApprovalStatus::Approved,
ApprovalDecision::Reject => ApprovalStatus::Rejected,
ApprovalDecision::Escalate => ApprovalStatus::Escalated,
ApprovalDecision::Defer | ApprovalDecision::RequestInfo => ApprovalStatus::Pending,
};
}
pub fn handle_timeout(&self, request: &mut ApprovalRequest) {
if let Some(default_action) = &request.default_action {
request.decision = Some(default_action.clone());
request.decided_by = Some("system:timeout".to_string());
request.decided_at = Some(Utc::now());
request.status = match default_action {
ApprovalDecision::Approve => ApprovalStatus::Approved,
ApprovalDecision::Reject => ApprovalStatus::Rejected,
_ => ApprovalStatus::TimedOut,
};
} else {
request.status = ApprovalStatus::TimedOut;
}
}
pub fn evaluate_condition(
&self,
condition: &str,
context: &serde_json::Value,
) -> Result<bool, String> {
let parts: Vec<&str> = condition.split_whitespace().collect();
if parts.len() != 3 {
return Err(format!("Invalid condition format: {}", condition));
}
let field = parts[0];
let operator = parts[1];
let value_str = parts[2];
let field_value = context
.get(field)
.and_then(|v| v.as_f64())
.ok_or_else(|| format!("Field not found or not numeric: {}", field))?;
let compare_value: f64 = value_str
.parse()
.map_err(|_| format!("Invalid comparison value: {}", value_str))?;
let result = match operator {
">" => field_value > compare_value,
">=" => field_value >= compare_value,
"<" => field_value < compare_value,
"<=" => field_value <= compare_value,
"==" | "=" => (field_value - compare_value).abs() < f64::EPSILON,
"!=" => (field_value - compare_value).abs() >= f64::EPSILON,
_ => return Err(format!("Unknown operator: {}", operator)),
};
Ok(result)
}
}
#[derive(Debug, Clone)]
pub struct EmailContent {
pub subject: String,
pub body: String,
pub html_body: Option<String>,
}
impl ApprovalRequest {
pub fn to_dynamic(&self) -> Dynamic {
let mut map = Map::new();
map.insert("id".into(), self.id.to_string().into());
map.insert("bot_id".into(), self.bot_id.to_string().into());
map.insert("session_id".into(), self.session_id.to_string().into());
map.insert("initiated_by".into(), self.initiated_by.to_string().into());
map.insert("approval_type".into(), self.approval_type.clone().into());
map.insert(
"status".into(),
format!("{:?}", self.status).to_lowercase().into(),
);
map.insert("channel".into(), self.channel.to_string().into());
map.insert("recipient".into(), self.recipient.clone().into());
map.insert("context".into(), json_to_dynamic(&self.context));
map.insert("message".into(), self.message.clone().into());
map.insert(
"timeout_seconds".into(),
i64::from(self.timeout_seconds as u32).into(),
);
map.insert("current_level".into(), i64::from(self.current_level).into());
map.insert("total_levels".into(), i64::from(self.total_levels).into());
map.insert("created_at".into(), self.created_at.to_rfc3339().into());
map.insert("expires_at".into(), self.expires_at.to_rfc3339().into());
if let Some(ref decision) = self.decision {
map.insert(
"decision".into(),
format!("{:?}", decision).to_lowercase().into(),
);
}
if let Some(ref decided_by) = self.decided_by {
map.insert("decided_by".into(), decided_by.clone().into());
}
if let Some(ref decided_at) = self.decided_at {
map.insert("decided_at".into(), decided_at.to_rfc3339().into());
}
if let Some(ref comments) = self.comments {
map.insert("comments".into(), comments.clone().into());
}
Dynamic::from(map)
}
}
fn json_to_dynamic(value: &serde_json::Value) -> Dynamic {
match value {
serde_json::Value::Null => Dynamic::UNIT,
serde_json::Value::Bool(b) => Dynamic::from(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Dynamic::from(i)
} else if let Some(f) = n.as_f64() {
Dynamic::from(f)
} else {
Dynamic::UNIT
}
}
serde_json::Value::String(s) => Dynamic::from(s.clone()),
serde_json::Value::Array(arr) => {
let array: Array = arr.iter().map(json_to_dynamic).collect();
Dynamic::from(array)
}
serde_json::Value::Object(obj) => {
let mut map = Map::new();
for (k, v) in obj {
map.insert(k.clone().into(), json_to_dynamic(v));
}
Dynamic::from(map)
}
}
}
pub fn register_approval_keywords(engine: &mut Engine) {
engine.register_fn("approval_is_approved", |request: Map| -> bool {
request
.get("status")
.and_then(|v| v.clone().try_cast::<String>())
.map(|s| s == "approved")
.unwrap_or(false)
});
engine.register_fn("approval_is_rejected", |request: Map| -> bool {
request
.get("status")
.and_then(|v| v.clone().try_cast::<String>())
.map(|s| s == "rejected")
.unwrap_or(false)
});
engine.register_fn("approval_is_pending", |request: Map| -> bool {
request
.get("status")
.and_then(|v| v.clone().try_cast::<String>())
.map(|s| s == "pending")
.unwrap_or(false)
});
engine.register_fn("approval_is_timed_out", |request: Map| -> bool {
request
.get("status")
.and_then(|v| v.clone().try_cast::<String>())
.map(|s| s == "timedout")
.unwrap_or(false)
});
engine.register_fn("approval_decision", |request: Map| -> String {
request
.get("decision")
.and_then(|v| v.clone().try_cast::<String>())
.unwrap_or_else(|| "pending".to_string())
});
engine.register_fn("approval_decided_by", |request: Map| -> String {
request
.get("decided_by")
.and_then(|v| v.clone().try_cast::<String>())
.unwrap_or_default()
});
engine.register_fn("approval_comments", |request: Map| -> String {
request
.get("comments")
.and_then(|v| v.clone().try_cast::<String>())
.unwrap_or_default()
});
info!("Approval keywords registered");
}
pub const APPROVAL_SCHEMA: &str = r"
-- Approval requests
CREATE TABLE IF NOT EXISTS approval_requests (
id UUID PRIMARY KEY,
bot_id UUID NOT NULL,
session_id UUID NOT NULL,
initiated_by UUID NOT NULL,
approval_type VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
channel VARCHAR(50) NOT NULL,
recipient VARCHAR(500) NOT NULL,
context JSONB NOT NULL DEFAULT '{}',
message TEXT NOT NULL,
timeout_seconds INTEGER NOT NULL DEFAULT 3600,
default_action VARCHAR(50),
current_level INTEGER NOT NULL DEFAULT 1,
total_levels INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
reminders_sent JSONB NOT NULL DEFAULT '[]',
decision VARCHAR(50),
decided_by VARCHAR(500),
decided_at TIMESTAMP WITH TIME ZONE,
comments TEXT
);
-- Approval chains
CREATE TABLE IF NOT EXISTS approval_chains (
id UUID PRIMARY KEY,
name VARCHAR(200) NOT NULL,
bot_id UUID NOT NULL,
levels JSONB NOT NULL DEFAULT '[]',
stop_on_reject BOOLEAN NOT NULL DEFAULT true,
require_all BOOLEAN NOT NULL DEFAULT false,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(bot_id, name)
);
-- Approval audit log
CREATE TABLE IF NOT EXISTS approval_audit_log (
id UUID PRIMARY KEY,
request_id UUID NOT NULL REFERENCES approval_requests(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL,
actor VARCHAR(500) NOT NULL,
details JSONB NOT NULL DEFAULT '{}',
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
ip_address VARCHAR(50),
user_agent TEXT
);
-- Approval tokens (for secure links)
CREATE TABLE IF NOT EXISTS approval_tokens (
id UUID PRIMARY KEY,
request_id UUID NOT NULL REFERENCES approval_requests(id) ON DELETE CASCADE,
token VARCHAR(100) NOT NULL UNIQUE,
action VARCHAR(50) NOT NULL,
used BOOLEAN NOT NULL DEFAULT false,
used_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_approval_requests_bot_id ON approval_requests(bot_id);
CREATE INDEX IF NOT EXISTS idx_approval_requests_session_id ON approval_requests(session_id);
CREATE INDEX IF NOT EXISTS idx_approval_requests_status ON approval_requests(status);
CREATE INDEX IF NOT EXISTS idx_approval_requests_expires_at ON approval_requests(expires_at);
CREATE INDEX IF NOT EXISTS idx_approval_requests_pending ON approval_requests(status, expires_at)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_approval_audit_request_id ON approval_audit_log(request_id);
CREATE INDEX IF NOT EXISTS idx_approval_audit_timestamp ON approval_audit_log(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_approval_tokens_token ON approval_tokens(token);
CREATE INDEX IF NOT EXISTS idx_approval_tokens_request_id ON approval_tokens(request_id);
";
pub mod sql {
pub const INSERT_REQUEST: &str = r"
INSERT INTO approval_requests (
id, bot_id, session_id, initiated_by, approval_type, status,
channel, recipient, context, message, timeout_seconds,
default_action, current_level, total_levels, created_at,
expires_at, reminders_sent
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17
)
";
pub const UPDATE_REQUEST: &str = r"
UPDATE approval_requests
SET status = $2,
decision = $3,
decided_by = $4,
decided_at = $5,
comments = $6
WHERE id = $1
";
pub const GET_REQUEST: &str = r"
SELECT * FROM approval_requests WHERE id = $1
";
pub const GET_PENDING_REQUESTS: &str = r"
SELECT * FROM approval_requests
WHERE status = 'pending'
AND expires_at > NOW()
ORDER BY created_at ASC
";
pub const GET_EXPIRED_REQUESTS: &str = r"
SELECT * FROM approval_requests
WHERE status = 'pending'
AND expires_at <= NOW()
";
pub const GET_REQUESTS_BY_SESSION: &str = r"
SELECT * FROM approval_requests
WHERE session_id = $1
ORDER BY created_at DESC
";
pub const UPDATE_REMINDERS: &str = r"
UPDATE approval_requests
SET reminders_sent = reminders_sent || $2::jsonb
WHERE id = $1
";
pub const INSERT_AUDIT: &str = r"
INSERT INTO approval_audit_log (
id, request_id, action, actor, details, timestamp, ip_address, user_agent
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
";
pub const GET_AUDIT_LOG: &str = r"
SELECT * FROM approval_audit_log
WHERE request_id = $1
ORDER BY timestamp ASC
";
pub const INSERT_TOKEN: &str = r"
INSERT INTO approval_tokens (
id, request_id, token, action, expires_at, created_at
) VALUES (
$1, $2, $3, $4, $5, $6
)
";
pub const GET_TOKEN: &str = r"
SELECT * FROM approval_tokens
WHERE token = $1 AND used = false AND expires_at > NOW()
";
pub const USE_TOKEN: &str = r"
UPDATE approval_tokens
SET used = true, used_at = NOW()
WHERE token = $1
";
pub const INSERT_CHAIN: &str = r"
INSERT INTO approval_chains (
id, name, bot_id, levels, stop_on_reject, require_all, description, created_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
ON CONFLICT (bot_id, name)
DO UPDATE SET
levels = $4,
stop_on_reject = $5,
require_all = $6,
description = $7
";
pub const GET_CHAIN: &str = r"
SELECT * FROM approval_chains
WHERE bot_id = $1 AND name = $2
";
}