Skip to main content

schwab_cli/
auth_reminder.rs

1//! Schwab OAuth refresh-token expiry warnings (~7 day lifetime).
2
3use chrono::{DateTime, Utc};
4use schwab_api::{ClientConfig, Tokens};
5
6use crate::agent::state::AgentState;
7use crate::notify::TelegramNotifier;
8
9/// Warn when ≤2 days remain; urgent when ≤1 day.
10const SOON_THRESHOLD_SECS: i64 = 2 * 86400;
11const URGENT_THRESHOLD_SECS: i64 = 86400;
12const REMINDER_COOLDOWN_SECS: i64 = 24 * 3600;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum AuthReminderLevel {
16    None,
17    Soon,
18    Urgent,
19    Expired,
20}
21
22impl AuthReminderLevel {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Self::None => "none",
26            Self::Soon => "soon",
27            Self::Urgent => "urgent",
28            Self::Expired => "expired",
29        }
30    }
31}
32
33#[derive(Debug, Clone)]
34pub struct AuthReminder {
35    pub level: AuthReminderLevel,
36    pub obtained_at: DateTime<Utc>,
37    pub refresh_expires_in_seconds: i64,
38    pub access_expires_in_seconds: i64,
39    pub message: String,
40}
41
42pub fn assess_refresh_token(tokens: &Tokens) -> AuthReminder {
43    let refresh_expires_in_seconds = tokens.refresh_expires_in_seconds();
44    let access_expires_in_seconds = tokens.expires_in_seconds();
45
46    let mut level = if refresh_expires_in_seconds <= 0 {
47        AuthReminderLevel::Expired
48    } else if refresh_expires_in_seconds <= URGENT_THRESHOLD_SECS {
49        AuthReminderLevel::Urgent
50    } else if refresh_expires_in_seconds <= SOON_THRESHOLD_SECS {
51        AuthReminderLevel::Soon
52    } else {
53        AuthReminderLevel::None
54    };
55
56    if tokens.is_expired() && level == AuthReminderLevel::None {
57        level = AuthReminderLevel::Soon;
58    }
59
60    let message = match level {
61        AuthReminderLevel::None => String::new(),
62        AuthReminderLevel::Soon => format!(
63            "Schwab login due in ~{} — run: schwab auth login",
64            format_days(refresh_expires_in_seconds)
65        ),
66        AuthReminderLevel::Urgent => format!(
67            "Schwab login needed within ~{} — run: schwab auth login now",
68            format_days(refresh_expires_in_seconds)
69        ),
70        AuthReminderLevel::Expired => {
71            "Schwab refresh token expired — run: schwab auth login".to_string()
72        }
73    };
74
75    AuthReminder {
76        level,
77        obtained_at: tokens.obtained_at,
78        refresh_expires_in_seconds,
79        access_expires_in_seconds,
80        message,
81    }
82}
83
84impl AuthReminder {
85    /// Extra context for status UIs (token issued, access + refresh horizons).
86    pub fn detail_line(&self) -> String {
87        let issued = format_ago((Utc::now() - self.obtained_at).num_seconds());
88        format!(
89            "issued {issued} ago · access {} · refresh {}",
90            format_duration(self.access_expires_in_seconds),
91            format_days(self.refresh_expires_in_seconds)
92        )
93    }
94}
95
96pub fn load_auth_reminder() -> Option<AuthReminder> {
97    let config = ClientConfig::from_env().ok()?;
98    let path = config.token_dir.join("tokens.json");
99    let raw = std::fs::read_to_string(path).ok()?;
100    let tokens: Tokens = serde_json::from_str(&raw).ok()?;
101    Some(assess_refresh_token(&tokens))
102}
103
104pub fn should_send_reminder(state: &AgentState, level: AuthReminderLevel) -> bool {
105    if level == AuthReminderLevel::None {
106        return false;
107    }
108    let level_s = level.as_str();
109    match (
110        state.last_auth_reminder_level.as_deref(),
111        state.last_auth_reminder_at,
112    ) {
113        (Some(prev), Some(at)) if prev == level_s => {
114            (Utc::now() - at).num_seconds() >= REMINDER_COOLDOWN_SECS
115        }
116        (Some(prev), _) if prev != level_s => true,
117        (None, _) => true,
118        _ => true,
119    }
120}
121
122pub fn record_reminder_sent(state: &mut AgentState, level: AuthReminderLevel) {
123    state.last_auth_reminder_level = Some(level.as_str().to_string());
124    state.last_auth_reminder_at = Some(Utc::now());
125}
126
127pub async fn maybe_notify_auth_reminder(
128    telegram: Option<&TelegramNotifier>,
129    state: &mut AgentState,
130    reminder: &AuthReminder,
131) {
132    if reminder.level == AuthReminderLevel::None {
133        return;
134    }
135    if !should_send_reminder(state, reminder.level) {
136        return;
137    }
138    let Some(tg) = telegram else {
139        return;
140    };
141    let text = format!("SCHWAB AUTH REMINDER\n{}", reminder.message);
142    if tg.send(&text).await.is_ok() {
143        record_reminder_sent(state, reminder.level);
144    }
145}
146
147pub async fn notify_auth_required(telegram: Option<&TelegramNotifier>, detail: &str) {
148    let Some(tg) = telegram else {
149        return;
150    };
151    let _ = tg
152        .send(&format!(
153            "SCHWAB AUTH REQUIRED\n{detail}\nRun: schwab auth login"
154        ))
155        .await;
156}
157
158fn format_days(secs: i64) -> String {
159    if secs < 3600 {
160        format!("{}m", (secs / 60).max(1))
161    } else if secs < 86_400 {
162        format!("{}h", secs / 3600)
163    } else {
164        format!("{}d", secs / 86_400)
165    }
166}
167
168fn format_duration(secs: i64) -> String {
169    if secs < 60 {
170        format!("{secs}s")
171    } else if secs < 3600 {
172        format!("{}m", secs / 60)
173    } else {
174        format!("{}h", secs / 3600)
175    }
176}
177
178fn format_ago(secs: i64) -> String {
179    if secs < 3600 {
180        format!("{}m", secs / 60)
181    } else if secs < 86_400 {
182        format!("{}h", secs / 3600)
183    } else {
184        format!("{}d", secs / 86_400)
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn sample_tokens(obtained_days_ago: i64) -> Tokens {
193        Tokens {
194            access_token: "a".into(),
195            refresh_token: "r".into(),
196            token_type: "Bearer".into(),
197            expires_at: Utc::now() + chrono::Duration::minutes(20),
198            scope: None,
199            obtained_at: Utc::now() - chrono::Duration::days(obtained_days_ago),
200        }
201    }
202
203    #[test]
204    fn urgent_when_one_day_left() {
205        let tokens = sample_tokens(6);
206        let r = assess_refresh_token(&tokens);
207        assert_eq!(r.level, AuthReminderLevel::Urgent);
208    }
209
210    #[test]
211    fn none_when_fresh() {
212        let tokens = sample_tokens(1);
213        let r = assess_refresh_token(&tokens);
214        assert_eq!(r.level, AuthReminderLevel::None);
215    }
216
217    #[test]
218    fn dedupe_same_level_within_cooldown() {
219        let state = AgentState {
220            last_auth_reminder_level: Some("urgent".into()),
221            last_auth_reminder_at: Some(Utc::now()),
222            ..Default::default()
223        };
224        assert!(!should_send_reminder(&state, AuthReminderLevel::Urgent));
225    }
226}