Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ dotenvy = "0.15"
tokio-util = "0.7"
rpassword = "7"
rand = "0.8"
chrono = "0.4"
5 changes: 4 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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)]
Expand Down
128 changes: 128 additions & 0 deletions src/greeting.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod api;
mod config;
mod greeting;
mod pipeline;
mod setup;
mod twilio;
Expand Down
18 changes: 11 additions & 7 deletions src/twilio/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message>,
speaking: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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
}
Expand Down