use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{Datelike, Utc};
use super::savings_summary::{aggregate, member_drilldown, TeamSavingsSummary};
use super::team::TeamAppState;
const TICK: Duration = Duration::from_hours(1);
const STATE_FILE: &str = "roi_webhook_state.json";
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct WebhookState {
#[serde(default)]
last_posted_week: Option<String>,
}
pub fn validate_webhook_url(url: &str) -> Result<(), String> {
if url.starts_with("https://") {
Ok(())
} else {
Err("roiWebhookUrl must be https:// — refusing to post team ROI over plaintext".into())
}
}
pub fn spawn_weekly_roi_webhook(state: TeamAppState, url: String) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tick(&state, &url).await;
tokio::time::sleep(TICK).await;
}
})
}
async fn tick(state: &TeamAppState, url: &str) {
let week = iso_week_key(Utc::now().date_naive());
let dir = state.team.savings_store_dir.lock().await.clone();
let state_path = dir.join(STATE_FILE);
if load_state(&state_path).last_posted_week.as_deref() == Some(week.as_str()) {
return;
}
let url_owned = url.to_string();
let posted = tokio::task::spawn_blocking(move || {
let summary = aggregate(&dir);
if summary.member_count == 0 {
return false;
}
let mover = top_mover(&dir, &summary);
let text = format_roi_message(&summary, &week, mover.as_deref());
let payload = payload_for(&url_owned, &text);
match post_webhook(&url_owned, &payload) {
Ok(()) => {
save_state(
&dir.join(STATE_FILE),
&WebhookState {
last_posted_week: Some(week),
},
);
true
}
Err(e) => {
tracing::warn!("team ROI webhook post failed (will retry next tick): {e}");
false
}
}
})
.await
.unwrap_or(false);
if posted {
tracing::info!("team ROI webhook posted weekly summary");
}
}
fn iso_week_key(date: chrono::NaiveDate) -> String {
let iso = date.iso_week();
format!("{}-W{:02}", iso.year(), iso.week())
}
fn load_state(path: &Path) -> WebhookState {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_state(path: &Path, state: &WebhookState) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(state) {
if let Err(e) = std::fs::write(path, json) {
tracing::warn!("could not persist ROI webhook state: {e}");
}
}
}
fn top_mover(dir: &Path, summary: &TeamSavingsSummary) -> Option<String> {
let mut best: Option<(String, u64)> = None;
for m in &summary.by_member {
let Some(drill) = member_drilldown(dir, &m.signer) else {
continue;
};
let series = &drill.series;
if series.is_empty() {
continue;
}
let last = series.last().map_or(0, |p| p.net_saved_tokens);
let base_idx = series.len().saturating_sub(8);
let base = series[base_idx].net_saved_tokens;
let delta = last.saturating_sub(base);
if delta > 0 && best.as_ref().is_none_or(|(_, b)| delta > *b) {
best = Some((
format!("{} (+{} tokens 7d)", drill.agent_id, compact(delta)),
delta,
));
}
}
best.map(|(label, _)| label)
}
fn compact(n: u64) -> String {
if n >= 1_000_000_000 {
format!("{:.1}B", n as f64 / 1e9)
} else if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1e6)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1e3)
} else {
n.to_string()
}
}
fn format_roi_message(summary: &TeamSavingsSummary, week: &str, top_mover: Option<&str>) -> String {
let t = &summary.totals;
let mut lines = vec![
format!("lean-ctx team ROI — {week}"),
format!(
"Net saved: {} tokens (~${:.2}) · {} measured actions · {} reporting member{}",
compact(t.net_saved_tokens),
t.saved_usd,
compact(t.total_events),
summary.member_count,
if summary.member_count == 1 { "" } else { "s" },
),
];
if summary.series.len() >= 2 {
let last = summary.series.last().unwrap();
let base_idx = summary.series.len().saturating_sub(8);
let base = &summary.series[base_idx];
let d_tokens = last.net_saved_tokens.saturating_sub(base.net_saved_tokens);
let d_usd = (last.saved_usd - base.saved_usd).max(0.0);
lines.push(format!(
"Last 7 days: +{} tokens (~${d_usd:.2})",
compact(d_tokens)
));
}
if let Some(mover) = top_mover {
lines.push(format!("Top mover: {mover}"));
}
if let Some(m) = summary.by_model.first() {
lines.push(format!(
"Top model: {} ({} tokens)",
m.model,
compact(m.saved_tokens)
));
}
if let Some(t) = summary.by_tool.first() {
lines.push(format!(
"Top tool: {} ({} tokens)",
t.tool,
compact(t.saved_tokens)
));
}
lines.join("\n")
}
fn payload_for(url: &str, text: &str) -> serde_json::Value {
let is_discord =
url.contains("discord.com/api/webhooks") || url.contains("discordapp.com/api/webhooks");
let is_slack = url.contains("hooks.slack.com");
if is_discord {
serde_json::json!({ "content": text })
} else if is_slack {
serde_json::json!({ "text": text })
} else {
serde_json::json!({ "text": text, "content": text })
}
}
fn post_webhook(url: &str, payload: &serde_json::Value) -> Result<(), String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(15)))
.build()
.into();
let resp = agent
.post(url)
.header("Content-Type", "application/json")
.send(payload.to_string().as_bytes())
.map_err(|e| e.to_string())?;
let code = resp.status().as_u16();
if (200..300).contains(&code) {
Ok(())
} else {
Err(format!("webhook returned HTTP {code}"))
}
}
#[allow(dead_code)]
pub fn state_path(savings_dir: &Path) -> PathBuf {
savings_dir.join(STATE_FILE)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http_server::savings_summary::{
MemberSavings, ModelRow, SavingsTotals, SeriesPoint, ToolRow,
};
fn summary() -> TeamSavingsSummary {
TeamSavingsSummary {
schema_version: 2,
generated_at: "2026-06-10T00:00:00Z".into(),
member_count: 2,
totals: SavingsTotals {
saved_tokens: 80_000_000,
net_saved_tokens: 78_000_000,
saved_usd: 196.42,
total_events: 36_001,
},
by_member: vec![MemberSavings {
signer: "aaaaaaaaaaaaaaaa".into(),
agent_id: "dev-laptop".into(),
saved_tokens: 50_000_000,
net_saved_tokens: 48_000_000,
saved_usd: 120.0,
total_events: 20_000,
last_reported: "2026-06-09T00:00:00Z".into(),
}],
by_model: vec![ModelRow {
model: "claude-opus".into(),
saved_tokens: 41_200_000,
saved_usd: 150.0,
}],
by_tool: vec![ToolRow {
tool: "ctx_read".into(),
saved_tokens: 28_900_000,
}],
series: vec![
SeriesPoint {
date: "2026-06-01".into(),
net_saved_tokens: 70_000_000,
saved_usd: 180.0,
total_events: 30_000,
},
SeriesPoint {
date: "2026-06-10".into(),
net_saved_tokens: 78_000_000,
saved_usd: 196.42,
total_events: 36_001,
},
],
window_days: 90,
}
}
#[test]
fn iso_week_key_flips_on_monday() {
let sun = chrono::NaiveDate::from_ymd_opt(2026, 6, 7).unwrap();
let mon = chrono::NaiveDate::from_ymd_opt(2026, 6, 8).unwrap();
assert_eq!(iso_week_key(sun), "2026-W23");
assert_eq!(iso_week_key(mon), "2026-W24");
}
#[test]
fn payload_shape_follows_vendor() {
let slack = payload_for("https://hooks.slack.com/services/T/B/X", "hi");
assert_eq!(slack["text"], "hi");
assert!(slack.get("content").is_none());
let discord = payload_for("https://discord.com/api/webhooks/1/x", "hi");
assert_eq!(discord["content"], "hi");
assert!(discord.get("text").is_none());
let generic = payload_for("https://example.com/hook", "hi");
assert_eq!(generic["text"], "hi");
assert_eq!(generic["content"], "hi");
}
#[test]
fn message_carries_totals_window_and_movers() {
let msg = format_roi_message(&summary(), "2026-W24", Some("dev-laptop (+8.0M tokens 7d)"));
assert!(msg.contains("2026-W24"));
assert!(msg.contains("78.0M tokens"));
assert!(msg.contains("$196.42"));
assert!(msg.contains("36.0k measured actions"));
assert!(msg.contains("2 reporting members"));
assert!(msg.contains("Last 7 days: +8.0M tokens"));
assert!(msg.contains("Top mover: dev-laptop"));
assert!(msg.contains("Top model: claude-opus (41.2M tokens)"));
assert!(msg.contains("Top tool: ctx_read (28.9M tokens)"));
assert!(msg.len() < 1000, "message must stay compact: {}", msg.len());
}
#[test]
fn state_roundtrip_and_default() {
let dir =
std::env::temp_dir().join(format!("leanctx_roi_webhook_state_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(STATE_FILE);
assert_eq!(load_state(&path).last_posted_week, None);
save_state(
&path,
&WebhookState {
last_posted_week: Some("2026-W24".into()),
},
);
assert_eq!(
load_state(&path).last_posted_week.as_deref(),
Some("2026-W24")
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn webhook_url_must_be_https() {
assert!(validate_webhook_url("https://hooks.slack.com/services/x").is_ok());
assert!(validate_webhook_url("http://hooks.slack.com/services/x").is_err());
assert!(validate_webhook_url("ftp://example.com").is_err());
}
#[test]
fn compact_formatting() {
assert_eq!(compact(950), "950");
assert_eq!(compact(4_200), "4.2k");
assert_eq!(compact(78_000_000), "78.0M");
assert_eq!(compact(1_500_000_000), "1.5B");
}
#[test]
fn post_webhook_roundtrip_against_local_listener() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let mut bodies = Vec::new();
for status in ["204 No Content", "500 Internal Server Error"] {
let (mut stream, _) = listener.accept().unwrap();
let mut raw = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = stream.read(&mut buf).unwrap();
if n == 0 {
break;
}
raw.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&raw);
if let Some(head_end) = text.find("\r\n\r\n") {
let content_len = text
.to_ascii_lowercase()
.lines()
.find_map(|l| {
l.strip_prefix("content-length:")
.map(str::trim)
.map(String::from)
})
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(0);
if raw.len() >= head_end + 4 + content_len {
break;
}
}
}
bodies.push(String::from_utf8_lossy(&raw).to_string());
let resp =
format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
stream.write_all(resp.as_bytes()).unwrap();
}
bodies
});
let url = format!("http://{addr}/hook");
let payload = serde_json::json!({ "content": "lean-ctx team ROI — 2026-W24" });
assert!(post_webhook(&url, &payload).is_ok());
let err = post_webhook(&url, &payload).unwrap_err();
assert!(err.contains("500"), "got: {err}");
let bodies = handle.join().unwrap();
assert!(bodies[0].contains("POST /hook"));
assert!(bodies[0]
.to_ascii_lowercase()
.contains("content-type: application/json"));
assert!(bodies[0].contains("lean-ctx team ROI"));
}
}