diff --git a/Cargo.lock b/Cargo.lock index c8d403e..8b8f626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -23,6 +32,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.8" @@ -127,6 +142,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -535,6 +563,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -806,6 +858,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1793,6 +1854,7 @@ dependencies = [ "axum", "base64", "bytes", + "chrono", "dotenvy", "hound", "rand 0.8.5", @@ -1946,6 +2008,41 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 124dcb9..ecb8ab4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ dotenvy = "0.15" tokio-util = "0.7" rpassword = "7" rand = "0.8" +chrono = "0.4" diff --git a/config.example.toml b/config.example.toml index 83774a0..0798d92 100644 --- a/config.example.toml +++ b/config.example.toml @@ -24,7 +24,10 @@ model = "inworld-tts-1.5-max" [claude] # Uses `claude` CLI from PATH — no API key needed session_timeout_secs = 300 -greeting = "Hello, this is Echo" +# Entity name used in greetings (default: "Echo") +name = "Echo" +# Static greeting override. Leave empty to use rotating time-aware greetings. +greeting = "" # Allow claude CLI to run tools without permission prompts (use with caution) dangerously_skip_permissions = false # Path to self document injected as system prompt on every turn diff --git a/src/config.rs b/src/config.rs index f72ce93..d32faca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -61,8 +61,10 @@ fn default_inworld_model() -> String { pub struct ClaudeConfig { #[serde(default = "default_session_timeout")] pub session_timeout_secs: u64, - #[serde(default = "default_greeting")] + #[serde(default)] pub greeting: String, + #[serde(default = "default_name")] + pub name: String, #[serde(default)] pub dangerously_skip_permissions: bool, #[serde(default)] @@ -73,8 +75,8 @@ fn default_session_timeout() -> u64 { 300 } -fn default_greeting() -> String { - "Hello, this is Echo".to_string() +fn default_name() -> String { + "Echo".to_string() } #[derive(Debug, Deserialize, Clone)] diff --git a/src/greeting.rs b/src/greeting.rs new file mode 100644 index 0000000..5427d17 --- /dev/null +++ b/src/greeting.rs @@ -0,0 +1,128 @@ +use chrono::{Local, Timelike}; +use rand::seq::SliceRandom; + +const ANYTIME: &[&str] = &[ + "Hey, it's {name}", + "Hi there, {name} here", + "Hello, this is {name}", + "{name} here, what's up?", +]; + +const MORNING: &[&str] = &["Good morning, {name} here", "Morning! It's {name}"]; + +const AFTERNOON: &[&str] = &[ + "Good afternoon, it's {name}", + "Hey, good afternoon, {name} here", +]; + +const EVENING: &[&str] = &["Good evening, this is {name}", "Evening! {name} here"]; + +const NIGHT: &[&str] = &[ + "Hey, it's late, but {name}'s here", + "{name} here, burning the midnight oil?", +]; + +fn time_pool(hour: u32) -> &'static [&'static str] { + match hour { + 5..=11 => MORNING, + 12..=16 => AFTERNOON, + 17..=20 => EVENING, + _ => NIGHT, + } +} + +/// Select a greeting based on the current time of day. +/// +/// Combines anytime greetings with time-specific ones and picks randomly. +/// The `{name}` placeholder is replaced with the provided name. +pub fn select_greeting(name: &str) -> String { + let hour = Local::now().hour(); + select_greeting_for_hour(name, hour) +} + +fn select_greeting_for_hour(name: &str, hour: u32) -> String { + let time_specific = time_pool(hour); + let mut pool: Vec<&str> = Vec::with_capacity(ANYTIME.len() + time_specific.len()); + pool.extend_from_slice(ANYTIME); + pool.extend_from_slice(time_specific); + + let mut rng = rand::thread_rng(); + let template = pool.choose(&mut rng).unwrap_or(&ANYTIME[0]); + template.replace("{name}", name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn greeting_contains_name() { + let greeting = select_greeting_for_hour("TestBot", 10); + assert!( + greeting.contains("TestBot"), + "greeting should contain entity name: {greeting}" + ); + } + + #[test] + fn greeting_no_placeholder_leftover() { + for hour in 0..24 { + let greeting = select_greeting_for_hour("Echo", hour); + assert!( + !greeting.contains("{name}"), + "placeholder not replaced at hour {hour}: {greeting}" + ); + } + } + + #[test] + fn greeting_never_empty() { + for hour in 0..24 { + let greeting = select_greeting_for_hour("X", hour); + assert!(!greeting.is_empty(), "empty greeting at hour {hour}"); + } + } + + #[test] + fn time_pool_morning() { + let pool = time_pool(8); + assert!(pool + .iter() + .any(|g| g.contains("morning") || g.contains("Morning"))); + } + + #[test] + fn time_pool_afternoon() { + let pool = time_pool(14); + assert!(pool.iter().any(|g| g.contains("afternoon"))); + } + + #[test] + fn time_pool_evening() { + let pool = time_pool(19); + assert!(pool + .iter() + .any(|g| g.contains("evening") || g.contains("Evening"))); + } + + #[test] + fn time_pool_night() { + let pool = time_pool(23); + assert!(pool + .iter() + .any(|g| g.contains("late") || g.contains("midnight"))); + } + + #[test] + fn time_pool_boundaries() { + // 4 AM = night, 5 AM = morning, 11 AM = morning, 12 PM = afternoon + assert_eq!(time_pool(4), NIGHT); + assert_eq!(time_pool(5), MORNING); + assert_eq!(time_pool(11), MORNING); + assert_eq!(time_pool(12), AFTERNOON); + assert_eq!(time_pool(16), AFTERNOON); + assert_eq!(time_pool(17), EVENING); + assert_eq!(time_pool(20), EVENING); + assert_eq!(time_pool(21), NIGHT); + } +} diff --git a/src/main.rs b/src/main.rs index 6497ce3..f0fe4ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod api; mod config; +mod greeting; mod pipeline; mod setup; mod twilio; diff --git a/src/twilio/media.rs b/src/twilio/media.rs index 933636c..064bd23 100644 --- a/src/twilio/media.rs +++ b/src/twilio/media.rs @@ -453,19 +453,23 @@ fn is_whisper_hallucination(transcript: &str) -> bool { WHISPER_HALLUCINATIONS.iter().any(|h| lower == *h) } -/// Speak the configured greeting when a call connects. +/// Speak a greeting when a call connects. +/// +/// If `greeting` is set in config, uses that exact text every time. +/// Otherwise, selects a time-aware greeting from the built-in pool. async fn send_greeting( stream_sid: &str, state: &AppState, tx: &mpsc::Sender, speaking: &AtomicBool, ) -> Result<(), Box> { - let greeting = &state.config.claude.greeting; - if greeting.is_empty() { - return Ok(()); - } - tracing::info!("Sending greeting"); - let mulaw = state.tts.synthesize(greeting).await?; + let greeting = if state.config.claude.greeting.is_empty() { + crate::greeting::select_greeting(&state.config.claude.name) + } else { + state.config.claude.greeting.clone() + }; + tracing::info!(greeting = %greeting, "Sending greeting"); + let mulaw = state.tts.synthesize(&greeting).await?; speaking.store(true, Ordering::Relaxed); send_audio(stream_sid, &mulaw, tx).await }