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::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    // Slack returns 200, Discord 204 — accept the whole 2xx class.
253    if (200..300).contains(&code) {
254        Ok(())
255    } else {
256        Err(format!("webhook returned HTTP {code}"))
257    }
258}
259
260/// Expose the state path for ops/debugging (`lean-ctx team … status` later).
261#[allow(dead_code)]
262pub 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        // 2026-06-07 is a Sunday (W23), 2026-06-08 a Monday (W24).
323        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        // Discord hard limit is 2000 chars — stay far below.
357        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    /// Real HTTP round trip against a local listener: proves the POST body,
398    /// content type and 2xx/5xx handling without any external service.
399    #[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                // Read until headers + declared body length are complete
411                // (header and body may arrive in separate TCP writes).
412                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        // 204 → Ok.
449        assert!(post_webhook(&url, &payload).is_ok());
450        // 500 → Err mentioning the code (state must not advance on this).
451        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        // ureq normalizes header casing — compare case-insensitively.
457        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}