#![allow(dead_code)]
use chrono::Timelike;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AutomationError {
#[error("Failed to read rules: {0}")]
ReadError(#[from] std::io::Error),
#[error("Failed to parse rules: {0}")]
ParseError(#[from] toml::de::Error),
#[error("Failed to serialize rules: {0}")]
SerializeError(#[from] toml::ser::Error),
#[error("Rule not found: {0}")]
NotFound(String),
#[error("Invalid rule configuration: {0}")]
InvalidConfig(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum RuleAction {
Notify(String),
ExitMeeting,
StartRecording,
StopRecording,
SendMessage(String),
RunCommand(String),
Log(String),
SendToMobile { message: String, provider: MobileProvider },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum MobileProvider {
#[default]
Telegram,
WhatsApp,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TriggerType {
MeetingSilence { duration_secs: u64 },
Idle { duration_secs: u64 },
ActivitySpike { threshold: u32, window_secs: u64 },
NoActivity { duration_secs: u64 },
TimeOfDay { hour: u8, minute: u8 },
IncomingCall,
Custom { event: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationRule {
pub id: String,
pub name: String,
pub description: String,
pub enabled: bool,
pub trigger: TriggerType,
pub actions: Vec<RuleAction>,
pub cooldown_secs: u64,
pub last_triggered: Option<i64>,
}
impl AutomationRule {
pub fn new(name: &str, trigger: TriggerType, actions: Vec<RuleAction>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
description: String::new(),
enabled: true,
trigger,
actions,
cooldown_secs: 60,
last_triggered: None,
}
}
pub fn meeting_silence(name: &str, duration_secs: u64, action: RuleAction) -> Self {
Self::new(
name,
TriggerType::MeetingSilence { duration_secs },
vec![action],
)
}
pub fn idle_detection(name: &str, duration_secs: u64, action: RuleAction) -> Self {
Self::new(
name,
TriggerType::Idle { duration_secs },
vec![action],
)
}
pub fn should_trigger(&self, now: i64) -> bool {
if !self.enabled {
return false;
}
if let Some(last) = self.last_triggered {
if now - last < self.cooldown_secs as i64 {
return false;
}
}
true
}
pub fn mark_triggered(&mut self, now: i64) {
self.last_triggered = Some(now);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AutomationConfig {
pub rules: Vec<AutomationRule>,
pub global_cooldown: u64,
pub notifications_enabled: bool,
}
impl AutomationConfig {
pub fn load() -> Result<Self, AutomationError> {
let path = Self::rules_path()?;
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)?;
let config: Self = toml::from_str(&content)?;
Ok(config)
}
pub fn save(&self) -> Result<(), AutomationError> {
let path = Self::rules_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;
Ok(())
}
fn rules_path() -> Result<PathBuf, AutomationError> {
let home = dirs::home_dir()
.ok_or(AutomationError::InvalidConfig("No home directory".into()))?;
Ok(home.join(".i-self").join("automation.toml"))
}
pub fn add_rule(&mut self, rule: AutomationRule) {
self.rules.push(rule);
}
pub fn remove_rule(&mut self, id: &str) -> Result<(), AutomationError> {
let initial_len = self.rules.len();
self.rules.retain(|r| r.id != id);
if self.rules.len() == initial_len {
return Err(AutomationError::NotFound(id.to_string()));
}
Ok(())
}
pub fn enable_rule(&mut self, id: &str) -> Result<(), AutomationError> {
let rule = self.rules.iter_mut()
.find(|r| r.id == id)
.ok_or(AutomationError::NotFound(id.to_string()))?;
rule.enabled = true;
Ok(())
}
pub fn disable_rule(&mut self, id: &str) -> Result<(), AutomationError> {
let rule = self.rules.iter_mut()
.find(|r| r.id == id)
.ok_or(AutomationError::NotFound(id.to_string()))?;
rule.enabled = false;
Ok(())
}
pub fn get_enabled_rules(&self) -> Vec<&AutomationRule> {
self.rules.iter().filter(|r| r.enabled).collect()
}
pub fn default_rules() -> Self {
let mut config = Self::default();
config.rules = vec![
AutomationRule {
id: uuid::Uuid::new_v4().to_string(),
name: "Meeting Silence Alert".to_string(),
description: "Notify when no one talks in a meeting for 1 minute".to_string(),
enabled: true,
trigger: TriggerType::MeetingSilence { duration_secs: 60 },
actions: vec![RuleAction::Notify("Meeting has been silent for 1 minute".to_string())],
cooldown_secs: 30,
last_triggered: None,
},
AutomationRule {
id: uuid::Uuid::new_v4().to_string(),
name: "Idle Detection".to_string(),
description: "Notify when idle for 5 minutes".to_string(),
enabled: true,
trigger: TriggerType::Idle { duration_secs: 300 },
actions: vec![RuleAction::Notify("You've been idle for 5 minutes".to_string())],
cooldown_secs: 60,
last_triggered: None,
},
AutomationRule {
id: uuid::Uuid::new_v4().to_string(),
name: "Long Idle Warning".to_string(),
description: "Warn after 15 minutes of inactivity".to_string(),
enabled: true,
trigger: TriggerType::Idle { duration_secs: 900 },
actions: vec![
RuleAction::Notify("You've been idle for 15 minutes! Time to get back to coding?".to_string()),
],
cooldown_secs: 300,
last_triggered: None,
},
AutomationRule {
id: uuid::Uuid::new_v4().to_string(),
name: "Break Reminder".to_string(),
description: "Remind to take a break every hour".to_string(),
enabled: false,
trigger: TriggerType::NoActivity { duration_secs: 3600 },
actions: vec![RuleAction::Notify("Time for a break! You've been working for a while.".to_string())],
cooldown_secs: 3600,
last_triggered: None,
},
AutomationRule {
id: uuid::Uuid::new_v4().to_string(),
name: "Activity Spike Detection".to_string(),
description: "Alert on unusually high activity (possible automation)".to_string(),
enabled: false,
trigger: TriggerType::ActivitySpike { threshold: 500, window_secs: 60 },
actions: vec![RuleAction::Log("High activity detected - possible automation".to_string())],
cooldown_secs: 300,
last_triggered: None,
},
AutomationRule {
id: uuid::Uuid::new_v4().to_string(),
name: "Incoming Call Alert".to_string(),
description: "Send alert to phone when incoming call detected in meeting".to_string(),
enabled: true,
trigger: TriggerType::IncomingCall,
actions: vec![
RuleAction::Notify("Incoming call detected!".to_string()),
RuleAction::SendToMobile {
message: "📞 Incoming call detected - check your meeting!".to_string(),
provider: MobileProvider::Telegram
},
],
cooldown_secs: 30,
last_triggered: None,
},
];
config
}
}
pub struct AutomationEngine {
config: AutomationConfig,
}
impl AutomationEngine {
pub fn new() -> Self {
Self {
config: AutomationConfig::load().unwrap_or_default(),
}
}
pub fn with_config(config: AutomationConfig) -> Self {
Self { config }
}
pub fn check_triggers(&mut self, event: &str, value: i64) -> Vec<RuleAction> {
let mut triggered = Vec::new();
let mut triggered_rule_ids = Vec::new();
let now = chrono::Utc::now().timestamp();
for rule in self.config.get_enabled_rules() {
if !rule.should_trigger(now) {
continue;
}
let should_trigger = match &rule.trigger {
TriggerType::MeetingSilence { duration_secs } => {
if event == "meeting_silence" {
value >= *duration_secs as i64
} else {
false
}
}
TriggerType::Idle { duration_secs } => {
if event == "idle" {
value >= *duration_secs as i64
} else {
false
}
}
TriggerType::NoActivity { duration_secs } => {
if event == "no_activity" {
value >= *duration_secs as i64
} else {
false
}
}
TriggerType::ActivitySpike { threshold, window_secs: _ } => {
if event == "activity_spike" {
value >= *threshold as i64
} else {
false
}
}
TriggerType::TimeOfDay { hour, minute } => {
if event == "time_check" {
let now = chrono::Local::now();
now.hour() as u8 == *hour && now.minute() as u8 == *minute
} else {
false
}
}
TriggerType::IncomingCall => {
event == "incoming_call"
}
TriggerType::Custom { event: custom_event } => {
event == custom_event
}
};
if should_trigger {
triggered.extend(rule.actions.clone());
triggered_rule_ids.push(rule.id.clone());
}
}
for id in triggered_rule_ids {
if let Some(rule) = self.config.rules.iter_mut().find(|r| r.id == id) {
rule.mark_triggered(now);
}
}
triggered
}
pub fn config(&self) -> &AutomationConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut AutomationConfig {
&mut self.config
}
pub fn reload(&mut self) -> Result<(), AutomationError> {
self.config = AutomationConfig::load()?;
Ok(())
}
pub async fn execute_actions(&self, actions: &[RuleAction]) -> Result<(), AutomationError> {
for action in actions {
match action {
RuleAction::Notify(msg) => {
info!("Sending notification: {}", msg);
if let Err(e) = crate::monitor::notification::send_notification("i-self", msg) {
warn!("Failed to send notification: {}", e);
}
}
RuleAction::ExitMeeting => {
info!("Exiting meeting...");
}
RuleAction::StartRecording => {
info!("Starting recording...");
}
RuleAction::StopRecording => {
info!("Stopping recording...");
}
RuleAction::SendMessage(msg) => {
info!("Sending message: {}", msg);
}
RuleAction::RunCommand(cmd) => {
info!("Running command: {}", cmd);
match tokio::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
.await
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.trim().is_empty() {
info!("[run-command stdout] {}", stdout.trim());
}
if !stderr.trim().is_empty() {
warn!("[run-command stderr] {}", stderr.trim());
}
if !output.status.success() {
warn!(
"run-command `{}` exited with {}",
cmd, output.status
);
}
}
Err(e) => warn!("run-command `{}` failed to spawn: {}", cmd, e),
}
}
RuleAction::Log(msg) => {
info!("Automation log: {}", msg);
}
RuleAction::SendToMobile { message, provider } => {
info!("Sending to mobile via {:?}: {}", provider, message);
self.send_to_mobile(message, provider).await;
}
}
}
Ok(())
}
async fn send_to_mobile(&self, message: &str, provider: &MobileProvider) {
match provider {
MobileProvider::Telegram => {
if let (Ok(token), Ok(chat_id)) = (
std::env::var("TELEGRAM_BOT_TOKEN"),
std::env::var("TELEGRAM_CHAT_ID")
) {
if let Err(e) = crate::messaging::telegram::send_message(&token, &chat_id, message).await {
warn!("Failed to send Telegram message: {}", e);
}
}
}
MobileProvider::WhatsApp => {
if let (Ok(api_key), Ok(from_phone), Ok(to_phone)) = (
std::env::var("WHATSAPP_API_KEY"),
std::env::var("WHATSAPP_PHONE"),
std::env::var("WHATSAPP_TO_PHONE").or_else(|_| std::env::var("WHATSAPP_PHONE")),
) {
if let Err(e) = crate::messaging::whatsapp::send_message(&api_key, &from_phone, &to_phone, message).await {
warn!("Failed to send WhatsApp message: {}", e);
}
}
}
}
}
}
impl Default for AutomationEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_rules() {
let config = AutomationConfig::default_rules();
assert!(!config.rules.is_empty());
let meeting_rule = config.rules.iter()
.find(|r| r.name.contains("Meeting"))
.expect("Should have meeting rule");
match &meeting_rule.trigger {
TriggerType::MeetingSilence { duration_secs } => {
assert_eq!(*duration_secs, 60);
}
_ => panic!("Expected MeetingSilence trigger"),
}
}
#[test]
fn test_idle_rule() {
let rule = AutomationRule::idle_detection("Test Idle", 300, RuleAction::Notify("Test".to_string()));
assert!(rule.enabled);
match &rule.trigger {
TriggerType::Idle { duration_secs } => {
assert_eq!(*duration_secs, 300);
}
_ => panic!("Expected Idle trigger"),
}
}
#[test]
fn test_rule_cooldown() {
let mut rule = AutomationRule::new("Test", TriggerType::Idle { duration_secs: 60 }, vec![]);
assert!(rule.should_trigger(0));
rule.mark_triggered(100);
assert!(!rule.should_trigger(150));
assert!(rule.should_trigger(200));
}
#[test]
fn test_automation_engine() {
let config = AutomationConfig::default();
let mut engine = AutomationEngine::with_config(config);
let actions = engine.check_triggers("idle", 400);
assert!(actions.is_empty());
}
#[test]
fn test_add_remove_rules() {
let mut config = AutomationConfig::default();
let rule = AutomationRule::idle_detection("Test", 60, RuleAction::Notify("Test".to_string()));
let id = rule.id.clone();
config.add_rule(rule);
assert_eq!(config.rules.len(), 1);
config.remove_rule(&id).unwrap();
assert!(config.rules.is_empty());
}
#[test]
fn test_enable_disable_rules() {
let mut config = AutomationConfig::default();
let rule = AutomationRule::new("Test", TriggerType::Idle { duration_secs: 60 }, vec![]);
let id = rule.id.clone();
config.add_rule(rule);
config.disable_rule(&id).unwrap();
let rule = config.rules.iter().find(|r| r.id == id).unwrap();
assert!(!rule.enabled);
config.enable_rule(&id).unwrap();
let rule = config.rules.iter().find(|r| r.id == id).unwrap();
assert!(rule.enabled);
}
#[test]
fn test_rule_serialization() {
let rule = AutomationRule::idle_detection("Test", 60, RuleAction::Notify("Test".to_string()));
let serialized = toml::to_string(&rule).unwrap();
let deserialized: AutomationRule = toml::from_str(&serialized).unwrap();
assert_eq!(rule.name, deserialized.name);
}
}