tuitbot_core/startup/
config.rs1use serde::{Deserialize, Serialize};
4use std::fmt;
5
6use crate::x_api::scopes::{self, ScopeAnalysis};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ApiTier {
15 Free,
17 Basic,
19 Pro,
21}
22
23impl fmt::Display for ApiTier {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 match self {
26 ApiTier::Free => write!(f, "Free"),
27 ApiTier::Basic => write!(f, "Basic"),
28 ApiTier::Pro => write!(f, "Pro"),
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct TierCapabilities {
36 pub mentions: bool,
38 pub discovery: bool,
40 pub posting: bool,
42 pub search: bool,
44}
45
46impl TierCapabilities {
47 pub fn for_tier(tier: ApiTier) -> Self {
49 match tier {
50 ApiTier::Free => Self {
51 mentions: false,
52 discovery: false,
53 posting: true,
54 search: false,
55 },
56 ApiTier::Basic | ApiTier::Pro => Self {
57 mentions: true,
58 discovery: true,
59 posting: true,
60 search: true,
61 },
62 }
63 }
64
65 pub fn enabled_loop_names(&self) -> Vec<&'static str> {
67 let mut loops = Vec::new();
68 if self.mentions {
69 loops.push("mentions");
70 }
71 if self.discovery {
72 loops.push("discovery");
73 }
74 loops.push("content");
76 loops.push("threads");
77 loops
78 }
79
80 pub fn format_status(&self) -> String {
82 let status = |enabled: bool| if enabled { "enabled" } else { "DISABLED" };
83 format!(
84 "Mentions: {}, Discovery: {}, Content: enabled, Threads: enabled",
85 status(self.mentions),
86 status(self.discovery),
87 )
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct StoredTokens {
98 pub access_token: String,
100
101 #[serde(default)]
103 pub refresh_token: Option<String>,
104
105 #[serde(default)]
107 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
108
109 #[serde(default)]
111 pub scopes: Vec<String>,
112}
113
114impl StoredTokens {
115 pub fn is_expired(&self) -> bool {
117 match self.expires_at {
118 Some(expires) => chrono::Utc::now() >= expires,
119 None => false,
120 }
121 }
122
123 pub fn time_until_expiry(&self) -> Option<chrono::TimeDelta> {
125 self.expires_at.map(|expires| expires - chrono::Utc::now())
126 }
127
128 pub fn format_expiry(&self) -> String {
130 match self.time_until_expiry() {
131 Some(duration) if duration.num_seconds() > 0 => {
132 let hours = duration.num_hours();
133 let minutes = duration.num_minutes() % 60;
134 if hours > 0 {
135 format!("{hours}h {minutes}m")
136 } else {
137 format!("{minutes}m")
138 }
139 }
140 Some(_) => "expired".to_string(),
141 None => "no expiry set".to_string(),
142 }
143 }
144
145 pub fn has_scope_info(&self) -> bool {
147 !self.scopes.is_empty()
148 }
149
150 pub fn has_scope(&self, scope: &str) -> bool {
152 self.scopes.iter().any(|granted| granted == scope)
153 }
154
155 pub fn analyze_scopes(&self) -> ScopeAnalysis {
157 scopes::analyze_scopes(&self.scopes)
158 }
159}
160
161#[derive(Debug, thiserror::Error)]
167pub enum StartupError {
168 #[error("configuration error: {0}")]
170 Config(String),
171
172 #[error("authentication required: run `tuitbot auth` first")]
174 AuthRequired,
175
176 #[error("authentication expired: run `tuitbot auth` to re-authenticate")]
178 AuthExpired,
179
180 #[error("token refresh failed: {0}")]
182 TokenRefreshFailed(String),
183
184 #[error("database error: {0}")]
186 Database(String),
187
188 #[error("LLM provider error: {0}")]
190 LlmError(String),
191
192 #[error("X API error: {0}")]
194 XApiError(String),
195
196 #[error("I/O error: {0}")]
198 Io(#[from] std::io::Error),
199
200 #[error("{0}")]
202 Other(String),
203}