From 87ee78265f73c4f2e9bc87bd334fe8063bde770b Mon Sep 17 00:00:00 2001 From: ADD-SP Date: Sat, 11 Apr 2026 21:13:03 -0700 Subject: [PATCH] feat(onboard): auto-detect messaging platform for cron delivery The weekly stats cron job always used local delivery (Hermes) / --no-deliver (OpenClaw) so the report went to a log file nobody checks. Now clawshell onboard probes for a configured messaging platform and delivers the stats report there instead. Hermes: read ~/.hermes/.env for DISCORD_BOT_TOKEN, TELEGRAM_BOT_TOKEN, or SLACK_BOT_TOKEN. First non-empty match adds --deliver to `hermes cron create`. OpenClaw: run `openclaw config get channels --json` and iterate [telegram, discord, slack, mattermost] in priority order. First platform whose config exists and isn't {enabled: false} replaces --no-deliver with --announce --channel . Both fall back to the previous no-delivery behavior when no platform is detected. Step 6 now prints the detected target (e.g. "deliver: discord" or "deliver: log only"). --- src/hermes_cli.rs | 104 +++++++++++++++++++++++++++++++------- src/main.rs | 29 ++++++++--- src/openclaw_cli.rs | 120 ++++++++++++++++++++++++++++++++------------ 3 files changed, 196 insertions(+), 57 deletions(-) diff --git a/src/hermes_cli.rs b/src/hermes_cli.rs index 41837fe..f4d0ad5 100644 --- a/src/hermes_cli.rs +++ b/src/hermes_cli.rs @@ -158,19 +158,49 @@ pub fn apply_onboard_hermes_config( } use crate::onboard::{STATS_CRON_JOB_NAME, STATS_CRON_PROMPT}; +use std::path::Path; -pub fn setup_hermes_stats_cron(runner: &mut R) -> Result<(), Box> { +const HERMES_PLATFORM_TOKENS: &[(&str, &str)] = &[ + ("TELEGRAM_BOT_TOKEN", "telegram"), + ("DISCORD_BOT_TOKEN", "discord"), + ("SLACK_BOT_TOKEN", "slack"), +]; + +pub fn detect_hermes_channel(home_dir: &Path) -> Option { + let content = std::fs::read_to_string(home_dir.join(".hermes").join(".env")).ok()?; + for &(key, platform) in HERMES_PLATFORM_TOKENS { + for line in content.lines() { + let line = line.trim(); + if let Some(value) = line.strip_prefix(key).and_then(|r| r.strip_prefix('=')) { + let value = value.trim().trim_matches('"').trim_matches('\''); + if !value.is_empty() { + return Some(platform.to_string()); + } + } + } + } + None +} + +pub fn setup_hermes_stats_cron( + runner: &mut R, + channel: Option<&str>, +) -> Result<(), Box> { + let mut args: Vec = vec![ + "cron".into(), + "create".into(), + "0 9 * * 1".into(), + STATS_CRON_PROMPT.into(), + "--skill".into(), + "get-clawshell-stats".into(), + "--name".into(), + STATS_CRON_JOB_NAME.into(), + ]; + if let Some(ch) = channel { + args.extend_from_slice(&["--deliver".into(), ch.into()]); + } let output = runner - .run(&[ - "cron".into(), - "create".into(), - "0 9 * * 1".into(), - STATS_CRON_PROMPT.into(), - "--skill".into(), - "get-clawshell-stats".into(), - "--name".into(), - STATS_CRON_JOB_NAME.into(), - ]) + .run(&args) .map_err(|e| format!("failed to run `hermes cron create`: {e}"))?; if !output.success { let status = output @@ -313,20 +343,56 @@ mod tests { } #[test] - fn test_setup_hermes_stats_cron_sends_correct_args() { + fn test_setup_hermes_stats_cron_no_channel() { let mut runner = FakeHermesRunner::default(); - setup_hermes_stats_cron(&mut runner).unwrap(); + setup_hermes_stats_cron(&mut runner, None).unwrap(); assert_eq!(runner.calls.len(), 1); let args = &runner.calls[0]; assert_eq!(args[0], "cron"); assert_eq!(args[1], "create"); - assert_eq!(args[2], "0 9 * * 1"); assert!(args[3].contains("get-clawshell-stats")); - assert!(args[3].contains("/admin/stats")); - assert!(args.contains(&"--skill".to_string())); - assert!(args.contains(&"get-clawshell-stats".to_string())); - assert!(args.contains(&"--name".to_string())); - assert!(args.contains(&STATS_CRON_JOB_NAME.to_string())); + assert!(!args.contains(&"--deliver".to_string())); + } + + #[test] + fn test_setup_hermes_stats_cron_with_channel() { + let mut runner = FakeHermesRunner::default(); + setup_hermes_stats_cron(&mut runner, Some("discord")).unwrap(); + assert_eq!(runner.calls.len(), 1); + let args = &runner.calls[0]; + assert!(args.contains(&"--deliver".to_string())); + assert!(args.contains(&"discord".to_string())); + } + + #[test] + fn test_detect_hermes_channel_finds_discord() { + let dir = tempfile::tempdir().unwrap(); + let hermes_dir = dir.path().join(".hermes"); + std::fs::create_dir_all(&hermes_dir).unwrap(); + std::fs::write( + hermes_dir.join(".env"), + "DISCORD_BOT_TOKEN=abc123\nSOME_OTHER=val\n", + ) + .unwrap(); + assert_eq!( + detect_hermes_channel(dir.path()), + Some("discord".to_string()) + ); + } + + #[test] + fn test_detect_hermes_channel_ignores_empty_value() { + let dir = tempfile::tempdir().unwrap(); + let hermes_dir = dir.path().join(".hermes"); + std::fs::create_dir_all(&hermes_dir).unwrap(); + std::fs::write(hermes_dir.join(".env"), "DISCORD_BOT_TOKEN=\n").unwrap(); + assert_eq!(detect_hermes_channel(dir.path()), None); + } + + #[test] + fn test_detect_hermes_channel_returns_none_on_missing_file() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(detect_hermes_channel(dir.path()), None); } #[test] diff --git a/src/main.rs b/src/main.rs index 0859aee..1ed3808 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1168,10 +1168,18 @@ fn apply_openclaw_onboarding_steps( onboard::upsert_managed_skill_manifest_entry(config_file, &skill.manifest_entry)?; tui::print_info("OpenClaw skill", &skill.path.display().to_string()); } - // Set up the weekly stats cron job. + // Set up the weekly stats cron job, delivering to the first + // configured messaging channel if one is detected. let mut openclaw_runner = openclaw_cli::RealOpenclawRunner; - match openclaw_cli::setup_openclaw_stats_cron(&mut openclaw_runner) { - Ok(()) => tui::print_info("Cron job", "clawshell-weekly-stats (Mon 09:00)"), + let channel = openclaw_cli::detect_openclaw_channel(&mut openclaw_runner); + match openclaw_cli::setup_openclaw_stats_cron(&mut openclaw_runner, channel.as_deref()) { + Ok(()) => { + let dest = channel.as_deref().unwrap_or("log only"); + tui::print_info( + "Cron job", + &format!("clawshell-weekly-stats (Mon 09:00, deliver: {dest})"), + ); + } Err(err) => { tui::print_warning(&format!("Failed to set up weekly stats cron job: {err}")) } @@ -1334,10 +1342,19 @@ fn apply_hermes_onboarding_steps( for path in &paths { tui::print_info("Hermes skill", &path.display().to_string()); } - // Set up the weekly stats cron job. + // Set up the weekly stats cron job, delivering to the first + // configured messaging channel if one is detected. + let (home_dir, _, _) = resolve_hermes_target_user()?; + let channel = hermes_cli::detect_hermes_channel(&home_dir); let mut hermes_runner = hermes_cli::RealHermesRunner; - match hermes_cli::setup_hermes_stats_cron(&mut hermes_runner) { - Ok(()) => tui::print_info("Cron job", "clawshell-weekly-stats (Mon 09:00)"), + match hermes_cli::setup_hermes_stats_cron(&mut hermes_runner, channel.as_deref()) { + Ok(()) => { + let dest = channel.as_deref().unwrap_or("log only"); + tui::print_info( + "Cron job", + &format!("clawshell-weekly-stats (Mon 09:00, deliver: {dest})"), + ); + } Err(err) => { tui::print_warning(&format!("Failed to set up weekly stats cron job: {err}")) } diff --git a/src/openclaw_cli.rs b/src/openclaw_cli.rs index 9304d4d..00c00e5 100644 --- a/src/openclaw_cli.rs +++ b/src/openclaw_cli.rs @@ -647,23 +647,45 @@ fn nested_value_or_empty_object(json: &Value, path: &[&str]) -> Value { use crate::onboard::{STATS_CRON_JOB_NAME, STATS_CRON_PROMPT}; -pub fn setup_openclaw_stats_cron(runner: &mut R) -> Result<(), Box> { - run_openclaw_command( - runner, - &[ - "cron", - "add", - "--name", - STATS_CRON_JOB_NAME, - "--cron", - "0 9 * * 1", - "--session", - "isolated", - "--message", - STATS_CRON_PROMPT, - "--no-deliver", - ], - )?; +const CHANNEL_PRIORITY: &[&str] = &["telegram", "discord", "slack", "mattermost"]; + +pub fn detect_openclaw_channel(runner: &mut R) -> Option { + let channels = + openclaw_config_get_json_at_path(runner, "channels", OpenclawApprovalMode::AutoApprove) + .ok()?; + let obj = channels.as_object()?; + for &platform in CHANNEL_PRIORITY { + if let Some(cfg) = obj.get(platform) { + if cfg.get("enabled").and_then(Value::as_bool) != Some(false) { + return Some(platform.to_string()); + } + } + } + None +} + +pub fn setup_openclaw_stats_cron( + runner: &mut R, + channel: Option<&str>, +) -> Result<(), Box> { + let mut args: Vec<&str> = vec![ + "cron", + "add", + "--name", + STATS_CRON_JOB_NAME, + "--cron", + "0 9 * * 1", + "--session", + "isolated", + "--message", + STATS_CRON_PROMPT, + ]; + if let Some(ch) = channel { + args.extend_from_slice(&["--announce", "--channel", ch]); + } else { + args.push("--no-deliver"); + } + run_openclaw_command(runner, &args)?; Ok(()) } @@ -1209,27 +1231,61 @@ mod tests { } #[test] - fn test_setup_openclaw_stats_cron_sends_correct_args() { + fn test_setup_openclaw_stats_cron_no_channel() { let mut runner = FakeOpenclawRunner { responses: VecDeque::from([ok_output("")]), ..Default::default() }; - setup_openclaw_stats_cron(&mut runner).unwrap(); - assert_eq!(runner.calls.len(), 1); + setup_openclaw_stats_cron(&mut runner, None).unwrap(); let args = &runner.calls[0]; - assert_eq!(args[0], "cron"); - assert_eq!(args[1], "add"); - assert!(args.contains(&"--name".to_string())); - assert!(args.contains(&STATS_CRON_JOB_NAME.to_string())); - assert!(args.contains(&"--cron".to_string())); - assert!(args.contains(&"0 9 * * 1".to_string())); - assert!(args.contains(&"--session".to_string())); - assert!(args.contains(&"isolated".to_string())); assert!(args.contains(&"--no-deliver".to_string())); - let message_idx = args.iter().position(|a| a == "--message").unwrap(); - let message = &args[message_idx + 1]; - assert!(message.contains("get-clawshell-stats")); - assert!(message.contains("/admin/stats")); + assert!(!args.contains(&"--announce".to_string())); + } + + #[test] + fn test_setup_openclaw_stats_cron_with_channel() { + let mut runner = FakeOpenclawRunner { + responses: VecDeque::from([ok_output("")]), + ..Default::default() + }; + setup_openclaw_stats_cron(&mut runner, Some("telegram")).unwrap(); + let args = &runner.calls[0]; + assert!(args.contains(&"--announce".to_string())); + assert!(args.contains(&"--channel".to_string())); + assert!(args.contains(&"telegram".to_string())); + assert!(!args.contains(&"--no-deliver".to_string())); + } + + #[test] + fn test_detect_openclaw_channel_finds_telegram() { + let channels = r#"{"telegram": {"botToken": "123:ABC"}, "discord": {"enabled": false}}"#; + let mut runner = FakeOpenclawRunner { + responses: VecDeque::from([ok_output(channels)]), + ..Default::default() + }; + assert_eq!( + detect_openclaw_channel(&mut runner), + Some("telegram".to_string()) + ); + } + + #[test] + fn test_detect_openclaw_channel_skips_disabled() { + let channels = r#"{"telegram": {"enabled": false}}"#; + let mut runner = FakeOpenclawRunner { + responses: VecDeque::from([ok_output(channels)]), + ..Default::default() + }; + assert_eq!(detect_openclaw_channel(&mut runner), None); + } + + #[test] + fn test_detect_openclaw_channel_returns_none_on_missing() { + let mut runner = FakeOpenclawRunner { + responses: VecDeque::from([failed_output(1, "missing config path")]), + ..Default::default() + }; + assert_eq!(detect_openclaw_channel(&mut runner), None); } #[test]