schwab_cli/
auth_reminder.rs1use chrono::{DateTime, Utc};
4use schwab_api::{ClientConfig, Tokens};
5
6use crate::agent::state::AgentState;
7use crate::notify::TelegramNotifier;
8
9const 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 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}