beam-daemon 0.5.0

Daemon process for beam that receives Feishu/Lark events and manages coding sessions
Documentation
use super::*;

pub(crate) fn load_config(paths: &BeamPaths) -> Result<Config> {
    match std::fs::read_to_string(paths.config_toml()) {
        Ok(raw) => Ok(toml::from_str(&raw)?),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
        Err(err) => Err(err.into()),
    }
}

pub(crate) fn load_bot_configs(paths: &BeamPaths) -> Result<HashMap<String, BotConfig>> {
    match std::fs::read_to_string(paths.bots_json()) {
        Ok(raw) => {
            let items = serde_json::from_str::<Vec<BotConfig>>(&raw)?;
            Ok(items
                .into_iter()
                .map(|cfg| (cfg.lark_app_id.clone(), cfg))
                .collect())
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
        Err(err) => Err(err.into()),
    }
}

pub(crate) async fn persist_sessions(
    paths: &BeamPaths,
    sessions: &HashMap<String, Session>,
) -> Result<()> {
    tokio::fs::create_dir_all(paths.sessions_dir()).await?;
    let tmp = paths.session_store_json().with_extension("json.tmp");
    let payload = serde_json::to_vec_pretty(sessions)?;
    tokio::fs::write(&tmp, payload).await?;
    tokio::fs::rename(tmp, paths.session_store_json()).await?;
    Ok(())
}

pub(crate) async fn persist_runtime_state(
    paths: &BeamPaths,
    state: &DaemonRuntimeState,
) -> Result<()> {
    tokio::fs::create_dir_all(paths.run_dir()).await?;
    let tmp = paths.runtime_state_json().with_extension("json.tmp");
    let payload = serde_json::to_vec_pretty(state)?;
    tokio::fs::write(&tmp, payload).await?;
    tokio::fs::rename(tmp, paths.runtime_state_json()).await?;
    Ok(())
}

pub(crate) async fn load_sessions(paths: &BeamPaths) -> Result<HashMap<String, Session>> {
    match tokio::fs::read(paths.session_store_json()).await {
        Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
        Err(err) => Err(err.into()),
    }
}

pub(crate) async fn load_recent_lark_events(
    paths: &BeamPaths,
) -> HashMap<String, std::time::Instant> {
    let path = paths.recent_lark_events_json();
    let entries: Vec<(String, u64)> = match beam_core::persist::read_json(&path) {
        Ok(Some(entries)) => entries,
        _ => return HashMap::new(),
    };
    let now_ms = chrono::Utc::now().timestamp_millis() as u64;
    let ttl_ms = 300_000u64;
    let mut map = HashMap::new();
    for (key, ts_ms) in entries {
        if now_ms.saturating_sub(ts_ms) < ttl_ms {
            let elapsed = now_ms.saturating_sub(ts_ms);
            map.insert(
                key,
                std::time::Instant::now() - std::time::Duration::from_millis(elapsed),
            );
        }
    }
    map
}

pub(crate) async fn save_recent_lark_events(
    paths: &BeamPaths,
    events: &HashMap<String, std::time::Instant>,
) {
    let now = std::time::Instant::now();
    let now_epoch_ms = chrono::Utc::now().timestamp_millis() as u64;
    let ttl_ms = 300_000u64;
    let entries: Vec<(String, u64)> = events
        .iter()
        .filter_map(|(key, instant)| {
            let elapsed = now.duration_since(*instant);
            if elapsed.as_millis() as u64 > ttl_ms {
                return None;
            }
            let seen_at_ms = now_epoch_ms.saturating_sub(elapsed.as_millis() as u64);
            Some((key.clone(), seen_at_ms))
        })
        .collect();
    let path = paths.recent_lark_events_json();
    if entries.is_empty() {
        let _ = tokio::fs::remove_file(&path).await;
        return;
    }
    let _ =
        tokio::task::spawn_blocking(move || beam_core::persist::atomic_write_json(&path, &entries))
            .await;
}