Skip to main content

lean_ctx/http_server/
roi_webhook.rs

1//! Weekly team-ROI webhook (GL #388) — posts the savings roll-up to Slack,
2//! Discord, or any generic JSON webhook once per ISO week.
3//!
4//! Design:
5//! - The team server itself owns the cron (hourly tick): it already holds the
6//!   savings store locally, so no control-plane round trip is needed.
7//! - Post-once-per-week is enforced through a tiny state file next to the
8//!   savings store (`roi_webhook_state.json`). A failed POST does **not**
9//!   advance the state, so the next tick retries; a week with zero reporting
10//!   members posts nothing (no synthetic numbers, no noise).
11//! - Payload shape is detected from the URL: Slack incoming webhooks take
12//!   `{"text": …}`, Discord webhooks take `{"content": …}`, anything else
13//!   gets both keys so generic receivers can pick.
14//! - HTTPS is enforced — `team.json` is operator-controlled, but a webhook
15//!   URL is the one field that leaves the box, so it gets the hard gate.
16
17use 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
25/// Hourly tick: cheap enough to be negligible, frequent enough that a
26/// restart or a transient webhook failure delays the weekly post by at most
27/// an hour.
28const TICK: Duration = Duration::from_hours(1);
29
30/// State file name, stored inside the savings store directory. The summary
31/// aggregator only reads `savings_*.jsonl`, so this never pollutes it.
32const STATE_FILE: &str = "roi_webhook_state.json";
33
34#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
35struct WebhookState {
36    /// ISO-week key (`2026-W24`) of the last successful post.
37    #[serde(default)]
38    last_posted_week: Option<String>,
39}
40
41/// Validate the webhook URL at boot: HTTPS only.
42pub 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
50/// Spawn the weekly poster. Call once from `serve_team` when
51/// `roiWebhookUrl` is configured and validated.
52pub 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
61/// One scheduler tick: post if this ISO week hasn't been posted yet and at
62/// least one member has reported.
63async 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            // Nothing reported yet — stay quiet and retry next tick, so the
77            // very first post happens as soon as real data exists.
78            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
107/// `2026-W24`-style key — flips Monday 00:00 UTC, which is exactly when the
108/// new weekly post becomes due.
109fn 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
132/// The member with the largest net-token gain over the trailing 7 days,
133/// computed from each member's own carry-forward series (real reported
134/// snapshots only). `None` when nobody moved.
135fn 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        // 7 days back (series is daily); clamp for short series.
147        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
160/// Human-compact token count (`78.0M`, `4.2k`).
161fn 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
173/// Render the weekly message — totals, 7-day window, top mover, top
174/// model/tool. Plain text by design: it renders identically in Slack,
175/// Discord and any generic receiver; no per-vendor block kits to maintain.
176fn 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    // Trailing-7d window from the team series (cumulative ⇒ delta of ends).
191    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
223/// Choose the payload shape from the webhook URL.
224fn 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        // Generic receiver: send both common keys.
234        serde_json::json!({ "text": text, "content": text })
235    }
236}
237
238/// POST the payload. Synchronous (callers run it inside `spawn_blocking`).
239fn post_webhook(url: &str, payload: &serde_json::Value) -> Result<(), String> {
240    let agent: ureq::Agent = ureq::Agent::config_builder()
241        .http_status_as_error(false)
242        .timeout_global(Some(Duration::from_secs(15)))
243        .build()
244        .into();
245    let resp = agent
246        .post(url)
247        .header("Content-Type", "application/json")
248        .send(payload.to_string().as_bytes())
249        .map_err(|e| e.to_string())?;
250    let code = resp.status().as_u16();
251    // Slack returns 200, Discord 204 — accept the whole 2xx class.
252    if (200..300).contains(&code) {
253        Ok(())
254    } else {
255        Err(format!("webhook returned HTTP {code}"))
256    }
257}
258
259/// Expose the state path for ops/debugging (`lean-ctx team … status` later).
260#[allow(dead_code)]
261pub fn state_path(savings_dir: &Path) -> PathBuf {
262    savings_dir.join(STATE_FILE)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::http_server::savings_summary::{
269        MemberSavings, ModelRow, SavingsTotals, SeriesPoint, ToolRow,
270    };
271
272    fn summary() -> TeamSavingsSummary {
273        TeamSavingsSummary {
274            schema_version: 2,
275            generated_at: "2026-06-10T00:00:00Z".into(),
276            member_count: 2,
277            totals: SavingsTotals {
278                saved_tokens: 80_000_000,
279                net_saved_tokens: 78_000_000,
280                saved_usd: 196.42,
281                total_events: 36_001,
282            },
283            by_member: vec![MemberSavings {
284                signer: "aaaaaaaaaaaaaaaa".into(),
285                agent_id: "dev-laptop".into(),
286                saved_tokens: 50_000_000,
287                net_saved_tokens: 48_000_000,
288                saved_usd: 120.0,
289                total_events: 20_000,
290                last_reported: "2026-06-09T00:00:00Z".into(),
291            }],
292            by_model: vec![ModelRow {
293                model: "claude-opus".into(),
294                saved_tokens: 41_200_000,
295                saved_usd: 150.0,
296            }],
297            by_tool: vec![ToolRow {
298                tool: "ctx_read".into(),
299                saved_tokens: 28_900_000,
300            }],
301            series: vec![
302                SeriesPoint {
303                    date: "2026-06-01".into(),
304                    net_saved_tokens: 70_000_000,
305                    saved_usd: 180.0,
306                    total_events: 30_000,
307                },
308                SeriesPoint {
309                    date: "2026-06-10".into(),
310                    net_saved_tokens: 78_000_000,
311                    saved_usd: 196.42,
312                    total_events: 36_001,
313                },
314            ],
315            window_days: 90,
316        }
317    }
318
319    #[test]
320    fn iso_week_key_flips_on_monday() {
321        // 2026-06-07 is a Sunday (W23), 2026-06-08 a Monday (W24).
322        let sun = chrono::NaiveDate::from_ymd_opt(2026, 6, 7).unwrap();
323        let mon = chrono::NaiveDate::from_ymd_opt(2026, 6, 8).unwrap();
324        assert_eq!(iso_week_key(sun), "2026-W23");
325        assert_eq!(iso_week_key(mon), "2026-W24");
326    }
327
328    #[test]
329    fn payload_shape_follows_vendor() {
330        let slack = payload_for("https://hooks.slack.com/services/T/B/X", "hi");
331        assert_eq!(slack["text"], "hi");
332        assert!(slack.get("content").is_none());
333
334        let discord = payload_for("https://discord.com/api/webhooks/1/x", "hi");
335        assert_eq!(discord["content"], "hi");
336        assert!(discord.get("text").is_none());
337
338        let generic = payload_for("https://example.com/hook", "hi");
339        assert_eq!(generic["text"], "hi");
340        assert_eq!(generic["content"], "hi");
341    }
342
343    #[test]
344    fn message_carries_totals_window_and_movers() {
345        let msg = format_roi_message(&summary(), "2026-W24", Some("dev-laptop (+8.0M tokens 7d)"));
346        assert!(msg.contains("2026-W24"));
347        assert!(msg.contains("78.0M tokens"));
348        assert!(msg.contains("$196.42"));
349        assert!(msg.contains("36.0k measured actions"));
350        assert!(msg.contains("2 reporting members"));
351        assert!(msg.contains("Last 7 days: +8.0M tokens"));
352        assert!(msg.contains("Top mover: dev-laptop"));
353        assert!(msg.contains("Top model: claude-opus (41.2M tokens)"));
354        assert!(msg.contains("Top tool: ctx_read (28.9M tokens)"));
355        // Discord hard limit is 2000 chars — stay far below.
356        assert!(msg.len() < 1000, "message must stay compact: {}", msg.len());
357    }
358
359    #[test]
360    fn state_roundtrip_and_default() {
361        let dir =
362            std::env::temp_dir().join(format!("leanctx_roi_webhook_state_{}", std::process::id()));
363        let _ = std::fs::remove_dir_all(&dir);
364        std::fs::create_dir_all(&dir).unwrap();
365        let path = dir.join(STATE_FILE);
366
367        assert_eq!(load_state(&path).last_posted_week, None);
368        save_state(
369            &path,
370            &WebhookState {
371                last_posted_week: Some("2026-W24".into()),
372            },
373        );
374        assert_eq!(
375            load_state(&path).last_posted_week.as_deref(),
376            Some("2026-W24")
377        );
378        let _ = std::fs::remove_dir_all(&dir);
379    }
380
381    #[test]
382    fn webhook_url_must_be_https() {
383        assert!(validate_webhook_url("https://hooks.slack.com/services/x").is_ok());
384        assert!(validate_webhook_url("http://hooks.slack.com/services/x").is_err());
385        assert!(validate_webhook_url("ftp://example.com").is_err());
386    }
387
388    #[test]
389    fn compact_formatting() {
390        assert_eq!(compact(950), "950");
391        assert_eq!(compact(4_200), "4.2k");
392        assert_eq!(compact(78_000_000), "78.0M");
393        assert_eq!(compact(1_500_000_000), "1.5B");
394    }
395
396    /// Real HTTP round trip against a local listener: proves the POST body,
397    /// content type and 2xx/5xx handling without any external service.
398    #[test]
399    fn post_webhook_roundtrip_against_local_listener() {
400        use std::io::{Read, Write};
401
402        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
403        let addr = listener.local_addr().unwrap();
404
405        let handle = std::thread::spawn(move || {
406            let mut bodies = Vec::new();
407            for status in ["204 No Content", "500 Internal Server Error"] {
408                let (mut stream, _) = listener.accept().unwrap();
409                // Read until headers + declared body length are complete
410                // (header and body may arrive in separate TCP writes).
411                let mut raw = Vec::new();
412                let mut buf = [0u8; 4096];
413                loop {
414                    let n = stream.read(&mut buf).unwrap();
415                    if n == 0 {
416                        break;
417                    }
418                    raw.extend_from_slice(&buf[..n]);
419                    let text = String::from_utf8_lossy(&raw);
420                    if let Some(head_end) = text.find("\r\n\r\n") {
421                        let content_len = text
422                            .to_ascii_lowercase()
423                            .lines()
424                            .find_map(|l| {
425                                l.strip_prefix("content-length:")
426                                    .map(str::trim)
427                                    .map(String::from)
428                            })
429                            .and_then(|v| v.parse::<usize>().ok())
430                            .unwrap_or(0);
431                        if raw.len() >= head_end + 4 + content_len {
432                            break;
433                        }
434                    }
435                }
436                bodies.push(String::from_utf8_lossy(&raw).to_string());
437                let resp =
438                    format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
439                stream.write_all(resp.as_bytes()).unwrap();
440            }
441            bodies
442        });
443
444        let url = format!("http://{addr}/hook");
445        let payload = serde_json::json!({ "content": "lean-ctx team ROI — 2026-W24" });
446
447        // 204 → Ok.
448        assert!(post_webhook(&url, &payload).is_ok());
449        // 500 → Err mentioning the code (state must not advance on this).
450        let err = post_webhook(&url, &payload).unwrap_err();
451        assert!(err.contains("500"), "got: {err}");
452
453        let bodies = handle.join().unwrap();
454        assert!(bodies[0].contains("POST /hook"));
455        // ureq normalizes header casing — compare case-insensitively.
456        assert!(
457            bodies[0]
458                .to_ascii_lowercase()
459                .contains("content-type: application/json")
460        );
461        assert!(bodies[0].contains("lean-ctx team ROI"));
462    }
463}