use chrono::{DateTime, Utc}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { pub providers: HashMap, pub models: HashMap, pub agents: HashMap, #[serde(default)] pub gateway: GatewayConfig, #[serde(default)] pub scheduler: SchedulerConfig, #[serde(default)] pub client: ClientConfig, #[serde(default)] pub channels: HashMap, #[serde(default)] pub skills: SkillsConfig, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SkillsConfig { #[serde(default = "default_skills_enabled")] pub enabled: bool, #[serde(default = "default_skills_sources")] pub sources: Vec, #[serde(default = "default_skills_max_index_chars")] pub max_index_chars: usize, #[serde(default = "default_skills_max_listed")] pub max_listed_skills: usize, } fn default_skills_enabled() -> bool { true } fn default_skills_sources() -> Vec { vec!["project".to_string(), "user".to_string()] } fn default_skills_max_index_chars() -> usize { 4_000 } fn default_skills_max_listed() -> usize { 32 } impl Default for SkillsConfig { fn default() -> Self { Self { enabled: default_skills_enabled(), sources: default_skills_sources(), max_index_chars: default_skills_max_index_chars(), max_listed_skills: default_skills_max_listed(), } } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FeishuChannelConfig { #[serde(default)] pub enabled: bool, pub app_id: String, pub app_secret: String, #[serde(default = "default_allow_from")] pub allow_from: Vec, #[serde(default)] pub agent: String, #[serde(default = "default_media_dir")] pub media_dir: String, /// Emoji type for message reactions (e.g. "THUMBSUP", "OK", "EYES"). #[serde(default = "default_reaction_emoji")] pub reaction_emoji: String, } fn default_allow_from() -> Vec { vec!["*".to_string()] } fn default_media_dir() -> String { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); home.join(".picobot/media/feishu").to_string_lossy().to_string() } fn default_reaction_emoji() -> String { "Typing".to_string() } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProviderConfig { #[serde(rename = "type")] pub provider_type: String, pub base_url: String, pub api_key: String, #[serde(default)] pub extra_headers: HashMap, #[serde(default = "default_llm_timeout_secs")] pub llm_timeout_secs: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ModelConfig { pub model_id: String, #[serde(default)] pub temperature: Option, #[serde(default)] pub max_tokens: Option, #[serde(flatten)] pub extra: HashMap, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentConfig { pub provider: String, pub model: String, #[serde(default = "default_max_tool_iterations")] pub max_tool_iterations: usize, #[serde(default = "default_token_limit")] pub token_limit: usize, } fn default_max_tool_iterations() -> usize { 20 } fn default_token_limit() -> usize { 128_000 } fn default_llm_timeout_secs() -> u64 { 120 } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct GatewayConfig { #[serde(default = "default_gateway_host")] pub host: String, #[serde(default = "default_gateway_port")] pub port: u16, #[serde(default)] pub show_tool_results: bool, #[serde(default, rename = "session_ttl_hours")] pub session_ttl_hours: Option, #[serde(default = "default_agent_prompt_reinject_every", rename = "agent_prompt_reinject_every")] pub agent_prompt_reinject_every: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ClientConfig { #[serde(default = "default_gateway_url")] pub gateway_url: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SchedulerConfig { #[serde(default)] pub enabled: bool, #[serde(default = "default_scheduler_tick_resolution_ms")] pub tick_resolution_ms: u64, #[serde(default = "default_scheduler_worker_queue_capacity")] pub worker_queue_capacity: usize, #[serde(default)] pub misfire_policy: SchedulerMisfirePolicy, #[serde(default)] pub jobs: Vec, } pub const BUILTIN_MEMORY_MAINTENANCE_JOB_ID: &str = "builtin.memory_maintenance_daily"; #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum SchedulerMisfirePolicy { CatchUp, #[default] Skip, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SchedulerJobConfig { pub id: String, #[serde(default = "default_scheduler_job_enabled")] pub enabled: bool, pub kind: SchedulerJobKind, #[serde(default)] pub schedule: Option, #[serde(default)] pub startup_delay_secs: u64, #[serde(default)] pub interval_secs: u64, #[serde(default)] pub target: SchedulerJobTarget, #[serde(default)] pub payload: serde_json::Value, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SchedulerJobKind { InternalEvent, OutboundMessage, AgentTask, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SchedulerJobTarget { #[serde(default)] pub channel: Option, #[serde(default)] pub chat_id: Option, #[serde(default)] pub reply_to: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SchedulerSchedule { Delay { seconds: u64, }, Interval { seconds: u64, #[serde(default)] startup_delay_secs: u64, }, At { timestamp: String, }, Cron { expression: String, }, } impl SchedulerJobConfig { pub fn resolved_schedule(&self) -> Result { if let Some(schedule) = &self.schedule { schedule.validate(&self.id)?; return Ok(schedule.normalized_for_storage()); } if self.interval_secs > 0 { return Ok(SchedulerSchedule::Interval { seconds: self.interval_secs, startup_delay_secs: self.startup_delay_secs, }); } Err(ConfigError::InvalidSchedulerJob(format!( "scheduler job '{}' requires schedule or interval_secs", self.id ))) } } impl SchedulerConfig { pub fn builtin_jobs() -> Vec { vec![SchedulerJobConfig { id: BUILTIN_MEMORY_MAINTENANCE_JOB_ID.to_string(), enabled: true, kind: SchedulerJobKind::InternalEvent, schedule: Some(SchedulerSchedule::Cron { expression: "0 19 * * *".to_string(), }), startup_delay_secs: 0, interval_secs: 0, target: SchedulerJobTarget::default(), payload: serde_json::json!({ "event": "memory_maintenance", "time_zone": "Asia/Shanghai", "local_time": "03:00" }), }] } pub fn effective_jobs(&self) -> Vec { let mut jobs = Self::builtin_jobs(); for configured in &self.jobs { if let Some(existing) = jobs.iter_mut().find(|job| job.id == configured.id) { *existing = configured.clone(); } else { jobs.push(configured.clone()); } } jobs } } impl SchedulerSchedule { pub fn validate(&self, job_id: &str) -> Result<(), ConfigError> { match self { SchedulerSchedule::Delay { seconds } => { if *seconds == 0 { return Err(ConfigError::InvalidSchedulerJob(format!( "scheduler job '{}' delay.seconds must be greater than 0", job_id ))); } } SchedulerSchedule::Interval { seconds, .. } => { if *seconds == 0 { return Err(ConfigError::InvalidSchedulerJob(format!( "scheduler job '{}' interval.seconds must be greater than 0", job_id ))); } } SchedulerSchedule::At { timestamp } => { DateTime::parse_from_rfc3339(timestamp).map_err(|err| { ConfigError::InvalidSchedulerJob(format!( "scheduler job '{}' invalid at.timestamp '{}': {}", job_id, timestamp, err )) })?; } SchedulerSchedule::Cron { expression } => { parse_scheduler_cron(expression).map_err(|err| { ConfigError::InvalidSchedulerJob(format!( "scheduler job '{}' invalid cron.expression '{}': {}", job_id, expression, err )) })?; } } Ok(()) } pub fn is_one_shot(&self) -> bool { matches!(self, SchedulerSchedule::Delay { .. } | SchedulerSchedule::At { .. }) } pub fn normalized_for_storage(&self) -> Self { match self { SchedulerSchedule::At { timestamp } => { let parsed = DateTime::parse_from_rfc3339(timestamp) .map(|value| value.with_timezone(&Utc).to_rfc3339()) .unwrap_or_else(|_| timestamp.clone()); SchedulerSchedule::At { timestamp: parsed } } other => other.clone(), } } pub fn display(&self) -> String { match self { SchedulerSchedule::Delay { seconds } => format!("delay:{}s", seconds), SchedulerSchedule::Interval { seconds, startup_delay_secs, } => format!("interval:{}s:start_delay:{}s", seconds, startup_delay_secs), SchedulerSchedule::At { timestamp } => format!("at:{}", timestamp), SchedulerSchedule::Cron { expression } => format!("cron:{}", expression), } } } fn parse_scheduler_cron(expression: &str) -> Result { let normalized = normalize_cron_expression(expression); cron::Schedule::from_str(&normalized) } fn normalize_cron_expression(expression: &str) -> String { let parts: Vec<&str> = expression.split_whitespace().collect(); if parts.len() == 5 { format!("0 {}", expression.trim()) } else { expression.trim().to_string() } } fn default_scheduler_tick_resolution_ms() -> u64 { 1_000 } fn default_scheduler_worker_queue_capacity() -> usize { 64 } fn default_scheduler_job_enabled() -> bool { true } fn default_gateway_host() -> String { "127.0.0.1".to_string() } fn default_gateway_port() -> u16 { 19876 } fn default_gateway_url() -> String { "ws://127.0.0.1:19876/ws".to_string() } fn default_agent_prompt_reinject_every() -> u64 { 100 } impl Default for GatewayConfig { fn default() -> Self { Self { host: default_gateway_host(), port: default_gateway_port(), show_tool_results: false, session_ttl_hours: None, agent_prompt_reinject_every: default_agent_prompt_reinject_every(), } } } impl Default for ClientConfig { fn default() -> Self { Self { gateway_url: default_gateway_url(), } } } impl Default for SchedulerConfig { fn default() -> Self { Self { enabled: true, tick_resolution_ms: default_scheduler_tick_resolution_ms(), worker_queue_capacity: default_scheduler_worker_queue_capacity(), misfire_policy: SchedulerMisfirePolicy::default(), jobs: Vec::new(), } } } #[derive(Debug, Clone)] pub struct LLMProviderConfig { pub provider_type: String, pub name: String, pub base_url: String, pub api_key: String, pub extra_headers: HashMap, pub llm_timeout_secs: u64, pub model_id: String, pub temperature: Option, pub max_tokens: Option, pub model_extra: HashMap, pub max_tool_iterations: usize, pub token_limit: usize, } fn get_default_config_path() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".picobot").join("config.json") } impl Config { pub fn load(path: &str) -> Result> { Self::load_from(Path::new(path)) } pub fn load_default() -> Result> { let path = get_default_config_path(); Self::load_from(&path) } fn load_from(path: &Path) -> Result> { load_env_file()?; let content = if path.exists() { tracing::info!(path = %path.display(), "Config loaded"); fs::read_to_string(path)? } else { // Fallback to current directory let fallback = Path::new("config.json"); if fallback.exists() { tracing::info!(path = %fallback.display(), "Config loaded from fallback path"); fs::read_to_string(fallback)? } else { return Err(Box::new(ConfigError::ConfigNotFound( path.to_string_lossy().to_string(), ))); } }; let content = resolve_env_placeholders(&content); let config: Config = serde_json::from_str(&content)?; Ok(config) } pub fn get_provider_config(&self, agent_name: &str) -> Result { let agent = self.agents.get(agent_name) .ok_or(ConfigError::AgentNotFound(agent_name.to_string()))?; let provider = self.providers.get(&agent.provider) .ok_or(ConfigError::ProviderNotFound(agent.provider.clone()))?; let model = self.models.get(&agent.model) .ok_or(ConfigError::ModelNotFound(agent.model.clone()))?; Ok(LLMProviderConfig { provider_type: provider.provider_type.clone(), name: agent.provider.clone(), base_url: provider.base_url.clone(), api_key: provider.api_key.clone(), extra_headers: provider.extra_headers.clone(), llm_timeout_secs: provider.llm_timeout_secs, model_id: model.model_id.clone(), temperature: model.temperature, max_tokens: model.max_tokens, model_extra: model.extra.clone(), max_tool_iterations: agent.max_tool_iterations, token_limit: agent.token_limit, }) } } #[derive(Debug)] pub enum ConfigError { ConfigNotFound(String), AgentNotFound(String), ProviderNotFound(String), ModelNotFound(String), InvalidSchedulerJob(String), } impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ConfigError::ConfigNotFound(path) => write!(f, "Config file not found: {}. Use CONFIG_PATH env var or place config in ~/.picobot/config.json", path), ConfigError::AgentNotFound(name) => write!(f, "Agent not found: {}", name), ConfigError::ProviderNotFound(name) => write!(f, "Provider not found: {}", name), ConfigError::ModelNotFound(name) => write!(f, "Model not found: {}", name), ConfigError::InvalidSchedulerJob(message) => write!(f, "Invalid scheduler job: {}", message), } } } impl std::error::Error for ConfigError {} fn load_env_file() -> Result<(), Box> { let env_path = Path::new(".env"); if env_path.exists() { let content = fs::read_to_string(env_path)?; for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some((key, value)) = line.split_once('=') { let key = key.trim(); let value = value.trim().trim_matches('"').trim_matches('\''); if !value.is_empty() { // SAFETY: Setting environment variables for the current process // is safe as we're only modifying our own process state unsafe { env::set_var(key, value) }; } } } } Ok(()) } fn resolve_env_placeholders(content: &str) -> String { let re = Regex::new(r"<([A-Z_]+)>").expect("invalid regex"); re.replace_all(content, |caps: ®ex::Captures| { let var_name = &caps[1]; env::var(var_name).unwrap_or_else(|_| caps[0].to_string()) }).to_string() } #[cfg(test)] mod tests { use super::*; fn write_test_config() -> tempfile::NamedTempFile { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write( file.path(), r#"{ "providers": { "aliyun": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": "test-key", "extra_headers": {} }, "volcengine": { "type": "openai", "base_url": "https://example.invalid/volc", "api_key": "test-key-2", "extra_headers": {} } }, "models": { "qwen-plus": { "model_id": "qwen-plus", "temperature": 0.0 }, "doubao-seed-2-0-lite-260215": { "model_id": "doubao-seed-2-0-lite-260215" } }, "agents": { "default": { "provider": "aliyun", "model": "qwen-plus" } }, "gateway": { "host": "0.0.0.0", "port": 19876, "agent_prompt_reinject_every": 120 } }"#, ) .unwrap(); file } #[test] fn test_config_load() { let file = write_test_config(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); // Check providers assert!(config.providers.contains_key("volcengine")); assert!(config.providers.contains_key("aliyun")); // Check models assert!(config.models.contains_key("doubao-seed-2-0-lite-260215")); assert!(config.models.contains_key("qwen-plus")); // Check agents assert!(config.agents.contains_key("default")); } #[test] fn test_get_provider_config() { let file = write_test_config(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); let provider_config = config.get_provider_config("default").unwrap(); assert_eq!(provider_config.provider_type, "openai"); assert_eq!(provider_config.name, "aliyun"); assert_eq!(provider_config.model_id, "qwen-plus"); assert_eq!(provider_config.temperature, Some(0.0)); assert_eq!(provider_config.llm_timeout_secs, 120); } #[test] fn test_provider_config_loads_custom_llm_timeout() { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write( file.path(), r#"{ "providers": { "aliyun": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": "test-key", "extra_headers": {}, "llm_timeout_secs": 400 } }, "models": { "qwen-plus": { "model_id": "qwen-plus" } }, "agents": { "default": { "provider": "aliyun", "model": "qwen-plus" } } }"#, ) .unwrap(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); let provider_config = config.get_provider_config("default").unwrap(); assert_eq!(provider_config.llm_timeout_secs, 400); } #[test] fn test_default_gateway_config() { let file = write_test_config(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); assert_eq!(config.gateway.host, "0.0.0.0"); assert_eq!(config.gateway.port, 19876); assert!(!config.gateway.show_tool_results); assert_eq!(config.gateway.agent_prompt_reinject_every, 120); } #[test] fn test_gateway_config_defaults_agent_prompt_reinject_every() { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write( file.path(), r#"{ "providers": { "aliyun": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": "test-key", "extra_headers": {} } }, "models": { "qwen-plus": { "model_id": "qwen-plus" } }, "agents": { "default": { "provider": "aliyun", "model": "qwen-plus" } } }"#, ) .unwrap(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); assert!(!config.gateway.show_tool_results); assert_eq!(config.gateway.agent_prompt_reinject_every, 100); } #[test] fn test_gateway_config_can_enable_tool_results() { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write( file.path(), r#"{ "providers": { "aliyun": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": "test-key", "extra_headers": {} } }, "models": { "qwen-plus": { "model_id": "qwen-plus" } }, "agents": { "default": { "provider": "aliyun", "model": "qwen-plus" } }, "gateway": { "show_tool_results": true } }"#, ) .unwrap(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); assert!(config.gateway.show_tool_results); } #[test] fn test_scheduler_config_defaults() { let file = write_test_config(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); assert!(config.scheduler.enabled); assert_eq!(config.scheduler.tick_resolution_ms, 1_000); assert_eq!(config.scheduler.worker_queue_capacity, 64); assert_eq!(config.scheduler.misfire_policy, SchedulerMisfirePolicy::Skip); assert!(config.scheduler.jobs.is_empty()); let effective_jobs = config.scheduler.effective_jobs(); assert_eq!(effective_jobs.len(), 1); assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID); assert_eq!(effective_jobs[0].kind, SchedulerJobKind::InternalEvent); assert_eq!( effective_jobs[0].resolved_schedule().unwrap(), SchedulerSchedule::Cron { expression: "0 19 * * *".to_string(), } ); } #[test] fn test_scheduler_effective_jobs_allows_builtin_override() { let mut scheduler = SchedulerConfig::default(); scheduler.jobs.push(SchedulerJobConfig { id: BUILTIN_MEMORY_MAINTENANCE_JOB_ID.to_string(), enabled: false, kind: SchedulerJobKind::InternalEvent, schedule: Some(SchedulerSchedule::Cron { expression: "15 2 * * *".to_string(), }), startup_delay_secs: 0, interval_secs: 0, target: SchedulerJobTarget::default(), payload: serde_json::json!({ "event": "memory_maintenance", "time_zone": "UTC", "local_time": "02:15" }), }); scheduler.jobs.push(SchedulerJobConfig { id: "custom.reminder".to_string(), enabled: true, kind: SchedulerJobKind::InternalEvent, schedule: Some(SchedulerSchedule::Delay { seconds: 30 }), startup_delay_secs: 0, interval_secs: 0, target: SchedulerJobTarget::default(), payload: serde_json::json!({"event": "custom"}), }); let effective_jobs = scheduler.effective_jobs(); assert_eq!(effective_jobs.len(), 2); assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID); assert!(!effective_jobs[0].enabled); assert_eq!( effective_jobs[0].resolved_schedule().unwrap(), SchedulerSchedule::Cron { expression: "15 2 * * *".to_string(), } ); assert_eq!(effective_jobs[1].id, "custom.reminder"); } #[test] fn test_scheduler_config_loads_interval_compat_jobs() { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write( file.path(), r#"{ "providers": { "aliyun": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": "test-key", "extra_headers": {} } }, "models": { "qwen-plus": { "model_id": "qwen-plus" } }, "agents": { "default": { "provider": "aliyun", "model": "qwen-plus" } }, "scheduler": { "enabled": true, "tick_resolution_ms": 500, "worker_queue_capacity": 8, "misfire_policy": "catch_up", "jobs": [ { "id": "heartbeat.reminder", "kind": "outbound_message", "interval_secs": 60, "startup_delay_secs": 5, "target": { "channel": "feishu", "chat_id": "oc_demo" }, "payload": { "content": "heartbeat" } } ] } }"#, ) .unwrap(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); assert!(config.scheduler.enabled); assert_eq!(config.scheduler.tick_resolution_ms, 500); assert_eq!(config.scheduler.worker_queue_capacity, 8); assert_eq!(config.scheduler.misfire_policy, SchedulerMisfirePolicy::CatchUp); assert_eq!(config.scheduler.jobs.len(), 1); let job = &config.scheduler.jobs[0]; assert_eq!(job.id, "heartbeat.reminder"); assert!(job.enabled); assert_eq!(job.kind, SchedulerJobKind::OutboundMessage); assert_eq!(job.interval_secs, 60); assert_eq!(job.startup_delay_secs, 5); assert_eq!(job.target.channel.as_deref(), Some("feishu")); assert_eq!(job.target.chat_id.as_deref(), Some("oc_demo")); assert_eq!(job.payload.get("content").and_then(|value| value.as_str()), Some("heartbeat")); assert_eq!(job.resolved_schedule().unwrap(), SchedulerSchedule::Interval { seconds: 60, startup_delay_secs: 5, }); } #[test] fn test_scheduler_config_loads_schedule_variants() { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write( file.path(), r#"{ "providers": { "aliyun": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": "test-key", "extra_headers": {} } }, "models": { "qwen-plus": { "model_id": "qwen-plus" } }, "agents": { "default": { "provider": "aliyun", "model": "qwen-plus" } }, "scheduler": { "enabled": true, "jobs": [ { "id": "delay.job", "kind": "internal_event", "schedule": { "type": "delay", "seconds": 30 } }, { "id": "at.job", "kind": "outbound_message", "schedule": { "type": "at", "timestamp": "2026-04-23T09:00:00Z" }, "target": { "channel": "feishu", "chat_id": "oc_demo" }, "payload": { "content": "at run" } }, { "id": "cron.job", "kind": "internal_event", "schedule": { "type": "cron", "expression": "0 9 * * *" } } ] } }"#, ) .unwrap(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); assert_eq!(config.scheduler.jobs.len(), 3); assert_eq!( config.scheduler.jobs[0].resolved_schedule().unwrap(), SchedulerSchedule::Delay { seconds: 30 } ); assert_eq!(config.scheduler.jobs[0].kind, SchedulerJobKind::InternalEvent); assert_eq!( config.scheduler.jobs[1].resolved_schedule().unwrap(), SchedulerSchedule::At { timestamp: "2026-04-23T09:00:00+00:00".to_string(), } ); assert_eq!(config.scheduler.jobs[1].kind, SchedulerJobKind::OutboundMessage); assert_eq!( config.scheduler.jobs[2].resolved_schedule().unwrap(), SchedulerSchedule::Cron { expression: "0 9 * * *".to_string(), } ); assert_eq!(config.scheduler.jobs[2].kind, SchedulerJobKind::InternalEvent); } #[test] fn test_scheduler_config_loads_agent_task_job() { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write( file.path(), r#"{ "providers": { "aliyun": { "type": "openai", "base_url": "https://example.invalid/v1", "api_key": "test-key", "extra_headers": {} } }, "models": { "qwen-plus": { "model_id": "qwen-plus" } }, "agents": { "default": { "provider": "aliyun", "model": "qwen-plus" } }, "scheduler": { "enabled": true, "jobs": [ { "id": "agent.daily_summary", "kind": "agent_task", "schedule": { "type": "cron", "expression": "0 9 * * *" }, "target": { "channel": "feishu", "chat_id": "oc_demo" }, "payload": { "prompt": "请总结今天待办" } } ] } }"#, ) .unwrap(); let config = Config::load(file.path().to_str().unwrap()).unwrap(); let job = &config.scheduler.jobs[0]; assert_eq!(job.kind, SchedulerJobKind::AgentTask); assert_eq!(job.target.channel.as_deref(), Some("feishu")); assert_eq!(job.target.chat_id.as_deref(), Some("oc_demo")); assert_eq!(job.payload.get("prompt").and_then(|value| value.as_str()), Some("请总结今天待办")); } #[test] fn test_scheduler_schedule_validation_rejects_invalid_values() { assert!(SchedulerSchedule::Delay { seconds: 0 } .validate("delay.job") .is_err()); assert!(SchedulerSchedule::Interval { seconds: 0, startup_delay_secs: 0, } .validate("interval.job") .is_err()); assert!(SchedulerSchedule::At { timestamp: "bad timestamp".to_string(), } .validate("at.job") .is_err()); assert!(SchedulerSchedule::Cron { expression: "bad cron".to_string(), } .validate("cron.job") .is_err()); } }