1use std::path::{Path, PathBuf};
18use std::time::Duration;
19
20use chrono::{Datelike, Utc};
21
22use super::savings_summary::{TeamSavingsSummary, aggregate, member_drilldown};
23use super::team::TeamAppState;
24
25const TICK: Duration = Duration::from_hours(1);
29
30const STATE_FILE: &str = "roi_webhook_state.json";
33
34#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
35struct WebhookState {
36 #[serde(default)]
38 last_posted_week: Option<String>,
39}
40
41pub fn validate_webhook_url(url: &str) -> Result<(), String> {
43 if url.starts_with("https://") {
44 Ok(())
45 } else {
46 Err("roiWebhookUrl must be https:// — refusing to post team ROI over plaintext".into())
47 }
48}
49
50pub fn spawn_weekly_roi_webhook(state: TeamAppState, url: String) -> tokio::task::JoinHandle<()> {
53 tokio::spawn(async move {
54 loop {
55 tick(&state, &url).await;
56 tokio::time::sleep(TICK).await;
57 }
58 })
59}
60
61async fn tick(state: &TeamAppState, url: &str) {
64 let week = iso_week_key(Utc::now().date_naive());
65 let dir = state.team.savings_store_dir.lock().await.clone();
66 let state_path = dir.join(STATE_FILE);
67
68 if load_state(&state_path).last_posted_week.as_deref() == Some(week.as_str()) {
69 return;
70 }
71
72 let url_owned = url.to_string();
73 let posted = tokio::task::spawn_blocking(move || {
74 let summary = aggregate(&dir);
75 if summary.member_count == 0 {
76 return false;
79 }
80 let mover = top_mover(&dir, &summary);
81 let text = format_roi_message(&summary, &week, mover.as_deref());
82 let payload = payload_for(&url_owned, &text);
83 match post_webhook(&url_owned, &payload) {
84 Ok(()) => {
85 save_state(
86 &dir.join(STATE_FILE),
87 &WebhookState {
88 last_posted_week: Some(week),
89 },
90 );
91 true
92 }
93 Err(e) => {
94 tracing::warn!("team ROI webhook post failed (will retry next tick): {e}");
95 false
96 }
97 }
98 })
99 .await
100 .unwrap_or(false);
101
102 if posted {
103 tracing::info!("team ROI webhook posted weekly summary");
104 }
105}
106
107fn iso_week_key(date: chrono::NaiveDate) -> String {
110 let iso = date.iso_week();
111 format!("{}-W{:02}", iso.year(), iso.week())
112}
113
114fn load_state(path: &Path) -> WebhookState {
115 std::fs::read_to_string(path)
116 .ok()
117 .and_then(|s| serde_json::from_str(&s).ok())
118 .unwrap_or_default()
119}
120
121fn save_state(path: &Path, state: &WebhookState) {
122 if let Some(parent) = path.parent() {
123 let _ = std::fs::create_dir_all(parent);
124 }
125 if let Ok(json) = serde_json::to_string_pretty(state)
126 && let Err(e) = std::fs::write(path, json)
127 {
128 tracing::warn!("could not persist ROI webhook state: {e}");
129 }
130}
131
132fn top_mover(dir: &Path, summary: &TeamSavingsSummary) -> Option<String> {
136 let mut best: Option<(String, u64)> = None;
137 for m in &summary.by_member {
138 let Some(drill) = member_drilldown(dir, &m.signer) else {
139 continue;
140 };
141 let series = &drill.series;
142 if series.is_empty() {
143 continue;
144 }
145 let last = series.last().map_or(0, |p| p.net_saved_tokens);
146 let base_idx = series.len().saturating_sub(8);
148 let base = series[base_idx].net_saved_tokens;
149 let delta = last.saturating_sub(base);
150 if delta > 0 && best.as_ref().is_none_or(|(_, b)| delta > *b) {
151 best = Some((
152 format!("{} (+{} tokens 7d)", drill.agent_id, compact(delta)),
153 delta,
154 ));
155 }
156 }
157 best.map(|(label, _)| label)
158}
159
160fn compact(n: u64) -> String {
162 if n >= 1_000_000_000 {
163 format!("{:.1}B", n as f64 / 1e9)
164 } else if n >= 1_000_000 {
165 format!("{:.1}M", n as f64 / 1e6)
166 } else if n >= 1_000 {
167 format!("{:.1}k", n as f64 / 1e3)
168 } else {
169 n.to_string()
170 }
171}
172
173fn format_roi_message(summary: &TeamSavingsSummary, week: &str, top_mover: Option<&str>) -> String {
177 let t = &summary.totals;
178 let mut lines = vec![
179 format!("lean-ctx team ROI — {week}"),
180 format!(
181 "Net saved: {} tokens (~${:.2}) · {} measured actions · {} reporting member{}",
182 compact(t.net_saved_tokens),
183 t.saved_usd,
184 compact(t.total_events),
185 summary.member_count,
186 if summary.member_count == 1 { "" } else { "s" },
187 ),
188 ];
189
190 if summary.series.len() >= 2 {
192 let last = summary.series.last().unwrap();
193 let base_idx = summary.series.len().saturating_sub(8);
194 let base = &summary.series[base_idx];
195 let d_tokens = last.net_saved_tokens.saturating_sub(base.net_saved_tokens);
196 let d_usd = (last.saved_usd - base.saved_usd).max(0.0);
197 lines.push(format!(
198 "Last 7 days: +{} tokens (~${d_usd:.2})",
199 compact(d_tokens)
200 ));
201 }
202
203 if let Some(mover) = top_mover {
204 lines.push(format!("Top mover: {mover}"));
205 }
206 if let Some(m) = summary.by_model.first() {
207 lines.push(format!(
208 "Top model: {} ({} tokens)",
209 m.model,
210 compact(m.saved_tokens)
211 ));
212 }
213 if let Some(t) = summary.by_tool.first() {
214 lines.push(format!(
215 "Top tool: {} ({} tokens)",
216 t.tool,
217 compact(t.saved_tokens)
218 ));
219 }
220 lines.join("\n")
221}
222
223fn payload_for(url: &str, text: &str) -> serde_json::Value {
225 let is_discord =
226 url.contains("discord.com/api/webhooks") || url.contains("discordapp.com/api/webhooks");
227 let is_slack = url.contains("hooks.slack.com");
228 if is_discord {
229 serde_json::json!({ "content": text })
230 } else if is_slack {
231 serde_json::json!({ "text": text })
232 } else {
233 serde_json::json!({ "text": text, "content": text })
235 }
236}
237
238fn post_webhook(url: &str, payload: &serde_json::Value) -> Result<(), String> {
240 let agent: ureq::Agent = ureq::config::Config::builder()
241 .tls_config(crate::core::http_client::platform_tls_config())
242 .http_status_as_error(false)
243 .timeout_global(Some(Duration::from_secs(15)))
244 .build()
245 .into();
246 let resp = agent
247 .post(url)
248 .header("Content-Type", "application/json")
249 .send(payload.to_string().as_bytes())
250 .map_err(|e| e.to_string())?;
251 let code = resp.status().as_u16();
252 if (200..300).contains(&code) {
254 Ok(())
255 } else {
256 Err(format!("webhook returned HTTP {code}"))
257 }
258}
259
260#[cfg_attr(not(test), allow(dead_code))] pub fn state_path(savings_dir: &Path) -> PathBuf {
263 savings_dir.join(STATE_FILE)
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::http_server::savings_summary::{
270 MemberSavings, ModelRow, SavingsTotals, SeriesPoint, ToolRow,
271 };
272
273 fn summary() -> TeamSavingsSummary {
274 TeamSavingsSummary {
275 schema_version: 2,
276 generated_at: "2026-06-10T00:00:00Z".into(),
277 member_count: 2,
278 totals: SavingsTotals {
279 saved_tokens: 80_000_000,
280 net_saved_tokens: 78_000_000,
281 saved_usd: 196.42,
282 total_events: 36_001,
283 },
284 by_member: vec![MemberSavings {
285 signer: "aaaaaaaaaaaaaaaa".into(),
286 agent_id: "dev-laptop".into(),
287 saved_tokens: 50_000_000,
288 net_saved_tokens: 48_000_000,
289 saved_usd: 120.0,
290 total_events: 20_000,
291 last_reported: "2026-06-09T00:00:00Z".into(),
292 }],
293 by_model: vec![ModelRow {
294 model: "claude-opus".into(),
295 saved_tokens: 41_200_000,
296 saved_usd: 150.0,
297 }],
298 by_tool: vec![ToolRow {
299 tool: "ctx_read".into(),
300 saved_tokens: 28_900_000,
301 }],
302 series: vec![
303 SeriesPoint {
304 date: "2026-06-01".into(),
305 net_saved_tokens: 70_000_000,
306 saved_usd: 180.0,
307 total_events: 30_000,
308 },
309 SeriesPoint {
310 date: "2026-06-10".into(),
311 net_saved_tokens: 78_000_000,
312 saved_usd: 196.42,
313 total_events: 36_001,
314 },
315 ],
316 window_days: 90,
317 }
318 }
319
320 #[test]
321 fn iso_week_key_flips_on_monday() {
322 let sun = chrono::NaiveDate::from_ymd_opt(2026, 6, 7).unwrap();
324 let mon = chrono::NaiveDate::from_ymd_opt(2026, 6, 8).unwrap();
325 assert_eq!(iso_week_key(sun), "2026-W23");
326 assert_eq!(iso_week_key(mon), "2026-W24");
327 }
328
329 #[test]
330 fn payload_shape_follows_vendor() {
331 let slack = payload_for("https://hooks.slack.com/services/T/B/X", "hi");
332 assert_eq!(slack["text"], "hi");
333 assert!(slack.get("content").is_none());
334
335 let discord = payload_for("https://discord.com/api/webhooks/1/x", "hi");
336 assert_eq!(discord["content"], "hi");
337 assert!(discord.get("text").is_none());
338
339 let generic = payload_for("https://example.com/hook", "hi");
340 assert_eq!(generic["text"], "hi");
341 assert_eq!(generic["content"], "hi");
342 }
343
344 #[test]
345 fn message_carries_totals_window_and_movers() {
346 let msg = format_roi_message(&summary(), "2026-W24", Some("dev-laptop (+8.0M tokens 7d)"));
347 assert!(msg.contains("2026-W24"));
348 assert!(msg.contains("78.0M tokens"));
349 assert!(msg.contains("$196.42"));
350 assert!(msg.contains("36.0k measured actions"));
351 assert!(msg.contains("2 reporting members"));
352 assert!(msg.contains("Last 7 days: +8.0M tokens"));
353 assert!(msg.contains("Top mover: dev-laptop"));
354 assert!(msg.contains("Top model: claude-opus (41.2M tokens)"));
355 assert!(msg.contains("Top tool: ctx_read (28.9M tokens)"));
356 assert!(msg.len() < 1000, "message must stay compact: {}", msg.len());
358 }
359
360 #[test]
361 fn state_roundtrip_and_default() {
362 let dir =
363 std::env::temp_dir().join(format!("leanctx_roi_webhook_state_{}", std::process::id()));
364 let _ = std::fs::remove_dir_all(&dir);
365 std::fs::create_dir_all(&dir).unwrap();
366 let path = dir.join(STATE_FILE);
367
368 assert_eq!(load_state(&path).last_posted_week, None);
369 save_state(
370 &path,
371 &WebhookState {
372 last_posted_week: Some("2026-W24".into()),
373 },
374 );
375 assert_eq!(
376 load_state(&path).last_posted_week.as_deref(),
377 Some("2026-W24")
378 );
379 let _ = std::fs::remove_dir_all(&dir);
380 }
381
382 #[test]
383 fn webhook_url_must_be_https() {
384 assert!(validate_webhook_url("https://hooks.slack.com/services/x").is_ok());
385 assert!(validate_webhook_url("http://hooks.slack.com/services/x").is_err());
386 assert!(validate_webhook_url("ftp://example.com").is_err());
387 }
388
389 #[test]
390 fn compact_formatting() {
391 assert_eq!(compact(950), "950");
392 assert_eq!(compact(4_200), "4.2k");
393 assert_eq!(compact(78_000_000), "78.0M");
394 assert_eq!(compact(1_500_000_000), "1.5B");
395 }
396
397 #[test]
400 fn post_webhook_roundtrip_against_local_listener() {
401 use std::io::{Read, Write};
402
403 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
404 let addr = listener.local_addr().unwrap();
405
406 let handle = std::thread::spawn(move || {
407 let mut bodies = Vec::new();
408 for status in ["204 No Content", "500 Internal Server Error"] {
409 let (mut stream, _) = listener.accept().unwrap();
410 let mut raw = Vec::new();
413 let mut buf = [0u8; 4096];
414 loop {
415 let n = stream.read(&mut buf).unwrap();
416 if n == 0 {
417 break;
418 }
419 raw.extend_from_slice(&buf[..n]);
420 let text = String::from_utf8_lossy(&raw);
421 if let Some(head_end) = text.find("\r\n\r\n") {
422 let content_len = text
423 .to_ascii_lowercase()
424 .lines()
425 .find_map(|l| {
426 l.strip_prefix("content-length:")
427 .map(str::trim)
428 .map(String::from)
429 })
430 .and_then(|v| v.parse::<usize>().ok())
431 .unwrap_or(0);
432 if raw.len() >= head_end + 4 + content_len {
433 break;
434 }
435 }
436 }
437 bodies.push(String::from_utf8_lossy(&raw).to_string());
438 let resp =
439 format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
440 stream.write_all(resp.as_bytes()).unwrap();
441 }
442 bodies
443 });
444
445 let url = format!("http://{addr}/hook");
446 let payload = serde_json::json!({ "content": "lean-ctx team ROI — 2026-W24" });
447
448 assert!(post_webhook(&url, &payload).is_ok());
450 let err = post_webhook(&url, &payload).unwrap_err();
452 assert!(err.contains("500"), "got: {err}");
453
454 let bodies = handle.join().unwrap();
455 assert!(bodies[0].contains("POST /hook"));
456 assert!(
458 bodies[0]
459 .to_ascii_lowercase()
460 .contains("content-type: application/json")
461 );
462 assert!(bodies[0].contains("lean-ctx team ROI"));
463 }
464}