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
104 changes: 85 additions & 19 deletions src/hermes_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,19 +158,49 @@ pub fn apply_onboard_hermes_config<R: HermesRunner>(
}

use crate::onboard::{STATS_CRON_JOB_NAME, STATS_CRON_PROMPT};
use std::path::Path;

pub fn setup_hermes_stats_cron<R: HermesRunner>(runner: &mut R) -> Result<(), Box<dyn Error>> {
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<String> {
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<R: HermesRunner>(
runner: &mut R,
channel: Option<&str>,
) -> Result<(), Box<dyn Error>> {
let mut args: Vec<String> = 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
Expand Down Expand Up @@ -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]
Expand Down
29 changes: 23 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))
}
Expand Down Expand Up @@ -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}"))
}
Expand Down
120 changes: 88 additions & 32 deletions src/openclaw_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R: OpenclawRunner>(runner: &mut R) -> Result<(), Box<dyn Error>> {
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<R: OpenclawRunner>(runner: &mut R) -> Option<String> {
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<R: OpenclawRunner>(
runner: &mut R,
channel: Option<&str>,
) -> Result<(), Box<dyn Error>> {
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(())
}

Expand Down Expand Up @@ -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]
Expand Down
Loading