1use std::fmt;
9use std::time::Duration;
10
11#[derive(Debug, Clone)]
20pub struct LoopTweet {
21 pub id: String,
23 pub text: String,
25 pub author_id: String,
27 pub author_username: String,
29 pub author_followers: u64,
31 pub created_at: String,
33 pub likes: u64,
35 pub retweets: u64,
37 pub replies: u64,
39}
40
41#[derive(Debug, Clone)]
43pub struct ScoreResult {
44 pub total: f32,
46 pub meets_threshold: bool,
48 pub matched_keywords: Vec<String>,
50}
51
52#[derive(Debug)]
57pub enum LoopError {
58 RateLimited {
60 retry_after: Option<u64>,
62 },
63 AuthExpired,
65 LlmFailure(String),
67 NetworkError(String),
69 StorageError(String),
71 Other(String),
73}
74
75impl fmt::Display for LoopError {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 match self {
78 LoopError::RateLimited { retry_after } => match retry_after {
79 Some(secs) => write!(f, "rate limited, retry after {secs}s"),
80 None => write!(f, "rate limited"),
81 },
82 LoopError::AuthExpired => write!(f, "authentication expired"),
83 LoopError::LlmFailure(msg) => write!(f, "LLM failure: {msg}"),
84 LoopError::NetworkError(msg) => write!(f, "network error: {msg}"),
85 LoopError::StorageError(msg) => write!(f, "storage error: {msg}"),
86 LoopError::Other(msg) => write!(f, "{msg}"),
87 }
88 }
89}
90
91#[derive(Debug)]
97pub enum ContentLoopError {
98 LlmFailure(String),
100 PostFailed(String),
102 StorageError(String),
104 NetworkError(String),
106 Other(String),
108}
109
110impl fmt::Display for ContentLoopError {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::LlmFailure(msg) => write!(f, "LLM failure: {msg}"),
114 Self::PostFailed(msg) => write!(f, "Post failed: {msg}"),
115 Self::StorageError(msg) => write!(f, "Storage error: {msg}"),
116 Self::NetworkError(msg) => write!(f, "Network error: {msg}"),
117 Self::Other(msg) => write!(f, "{msg}"),
118 }
119 }
120}
121
122impl std::error::Error for ContentLoopError {}
123
124#[async_trait::async_trait]
130pub trait MentionsFetcher: Send + Sync {
131 async fn get_mentions(&self, since_id: Option<&str>) -> Result<Vec<LoopTweet>, LoopError>;
133}
134
135#[async_trait::async_trait]
137pub trait TweetSearcher: Send + Sync {
138 async fn search_tweets(&self, query: &str) -> Result<Vec<LoopTweet>, LoopError>;
140}
141
142#[async_trait::async_trait]
144pub trait ReplyGenerator: Send + Sync {
145 async fn generate_reply(
150 &self,
151 tweet_text: &str,
152 author: &str,
153 mention_product: bool,
154 ) -> Result<String, LoopError>;
155}
156
157#[async_trait::async_trait]
159pub trait SafetyChecker: Send + Sync {
160 async fn can_reply(&self) -> bool;
162
163 async fn has_replied_to(&self, tweet_id: &str) -> bool;
165
166 async fn record_reply(&self, tweet_id: &str, reply_content: &str) -> Result<(), LoopError>;
168}
169
170pub trait TweetScorer: Send + Sync {
172 fn score(&self, tweet: &LoopTweet) -> ScoreResult;
174}
175
176#[async_trait::async_trait]
178pub trait LoopStorage: Send + Sync {
179 async fn get_cursor(&self, key: &str) -> Result<Option<String>, LoopError>;
181
182 async fn set_cursor(&self, key: &str, value: &str) -> Result<(), LoopError>;
184
185 async fn tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError>;
187
188 async fn store_discovered_tweet(
190 &self,
191 tweet: &LoopTweet,
192 score: f32,
193 keyword: &str,
194 ) -> Result<(), LoopError>;
195
196 async fn log_action(
198 &self,
199 action_type: &str,
200 status: &str,
201 message: &str,
202 ) -> Result<(), LoopError>;
203}
204
205#[async_trait::async_trait]
207pub trait PostSender: Send + Sync {
208 async fn send_reply(&self, tweet_id: &str, content: &str) -> Result<(), LoopError>;
210}
211
212#[async_trait::async_trait]
218pub trait TopicScorer: Send + Sync {
219 async fn get_top_topics(&self, limit: u32) -> Result<Vec<String>, ContentLoopError>;
221}
222
223#[async_trait::async_trait]
225pub trait TweetGenerator: Send + Sync {
226 async fn generate_tweet(&self, topic: &str) -> Result<String, ContentLoopError>;
228}
229
230#[async_trait::async_trait]
232pub trait ContentSafety: Send + Sync {
233 async fn can_post_tweet(&self) -> bool;
235 async fn can_post_thread(&self) -> bool;
237}
238
239#[async_trait::async_trait]
241pub trait ContentStorage: Send + Sync {
242 async fn last_tweet_time(
244 &self,
245 ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError>;
246
247 async fn last_thread_time(
249 &self,
250 ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError>;
251
252 async fn todays_tweet_times(
254 &self,
255 ) -> Result<Vec<chrono::DateTime<chrono::Utc>>, ContentLoopError>;
256
257 async fn post_tweet(&self, topic: &str, content: &str) -> Result<(), ContentLoopError>;
259
260 async fn create_thread(
262 &self,
263 topic: &str,
264 tweet_count: usize,
265 ) -> Result<String, ContentLoopError>;
266
267 async fn update_thread_status(
269 &self,
270 thread_id: &str,
271 status: &str,
272 tweet_count: usize,
273 root_tweet_id: Option<&str>,
274 ) -> Result<(), ContentLoopError>;
275
276 async fn store_thread_tweet(
278 &self,
279 thread_id: &str,
280 position: usize,
281 tweet_id: &str,
282 content: &str,
283 ) -> Result<(), ContentLoopError>;
284
285 async fn log_action(
287 &self,
288 action_type: &str,
289 status: &str,
290 message: &str,
291 ) -> Result<(), ContentLoopError>;
292}
293
294#[async_trait::async_trait]
299pub trait ThreadPoster: Send + Sync {
300 async fn post_tweet(&self, content: &str) -> Result<String, ContentLoopError>;
302
303 async fn reply_to_tweet(
305 &self,
306 in_reply_to: &str,
307 content: &str,
308 ) -> Result<String, ContentLoopError>;
309}
310
311#[derive(Debug)]
320pub struct ConsecutiveErrorTracker {
321 count: u32,
322 max_consecutive: u32,
323 pause_duration: Duration,
324}
325
326impl ConsecutiveErrorTracker {
327 pub fn new(max_consecutive: u32, pause_duration: Duration) -> Self {
332 Self {
333 count: 0,
334 max_consecutive,
335 pause_duration,
336 }
337 }
338
339 pub fn record_error(&mut self) -> bool {
341 self.count += 1;
342 self.count >= self.max_consecutive
343 }
344
345 pub fn record_success(&mut self) {
347 self.count = 0;
348 }
349
350 pub fn should_pause(&self) -> bool {
352 self.count >= self.max_consecutive
353 }
354
355 pub fn pause_duration(&self) -> Duration {
357 self.pause_duration
358 }
359
360 pub fn count(&self) -> u32 {
362 self.count
363 }
364
365 pub fn reset(&mut self) {
367 self.count = 0;
368 }
369}
370
371pub fn rate_limit_backoff(retry_after: Option<u64>, attempt: u32) -> Duration {
376 if let Some(secs) = retry_after {
377 Duration::from_secs(secs)
378 } else {
379 let base = 60u64;
380 let exp = base.saturating_mul(2u64.saturating_pow(attempt));
381 Duration::from_secs(exp.min(900)) }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn error_tracker_records_errors() {
391 let mut tracker = ConsecutiveErrorTracker::new(3, Duration::from_secs(300));
392 assert!(!tracker.should_pause());
393 assert_eq!(tracker.count(), 0);
394
395 assert!(!tracker.record_error()); assert!(!tracker.record_error()); assert!(tracker.record_error()); assert!(tracker.should_pause());
399 assert_eq!(tracker.count(), 3);
400 }
401
402 #[test]
403 fn error_tracker_resets_on_success() {
404 let mut tracker = ConsecutiveErrorTracker::new(3, Duration::from_secs(300));
405 tracker.record_error();
406 tracker.record_error();
407 tracker.record_success();
408 assert_eq!(tracker.count(), 0);
409 assert!(!tracker.should_pause());
410 }
411
412 #[test]
413 fn error_tracker_pause_duration() {
414 let tracker = ConsecutiveErrorTracker::new(5, Duration::from_secs(120));
415 assert_eq!(tracker.pause_duration(), Duration::from_secs(120));
416 }
417
418 #[test]
419 fn rate_limit_backoff_with_retry_after() {
420 assert_eq!(rate_limit_backoff(Some(30), 0), Duration::from_secs(30));
421 assert_eq!(rate_limit_backoff(Some(120), 5), Duration::from_secs(120));
422 }
423
424 #[test]
425 fn rate_limit_backoff_exponential() {
426 assert_eq!(rate_limit_backoff(None, 0), Duration::from_secs(60));
427 assert_eq!(rate_limit_backoff(None, 1), Duration::from_secs(120));
428 assert_eq!(rate_limit_backoff(None, 2), Duration::from_secs(240));
429 }
430
431 #[test]
432 fn rate_limit_backoff_capped_at_15_minutes() {
433 assert_eq!(rate_limit_backoff(None, 10), Duration::from_secs(900));
434 }
435
436 #[test]
437 fn loop_error_display() {
438 let err = LoopError::RateLimited {
439 retry_after: Some(30),
440 };
441 assert_eq!(err.to_string(), "rate limited, retry after 30s");
442
443 let err = LoopError::AuthExpired;
444 assert_eq!(err.to_string(), "authentication expired");
445
446 let err = LoopError::LlmFailure("timeout".to_string());
447 assert_eq!(err.to_string(), "LLM failure: timeout");
448 }
449
450 #[test]
451 fn loop_tweet_debug() {
452 let tweet = LoopTweet {
453 id: "123".to_string(),
454 text: "hello".to_string(),
455 author_id: "uid_123".to_string(),
456 author_username: "user".to_string(),
457 author_followers: 1000,
458 created_at: "2026-01-01T00:00:00Z".to_string(),
459 likes: 10,
460 retweets: 2,
461 replies: 1,
462 };
463 let debug = format!("{tweet:?}");
464 assert!(debug.contains("123"));
465 }
466
467 #[test]
468 fn content_loop_error_display() {
469 let err = ContentLoopError::LlmFailure("model down".to_string());
470 assert_eq!(err.to_string(), "LLM failure: model down");
471
472 let err = ContentLoopError::PostFailed("429".to_string());
473 assert_eq!(err.to_string(), "Post failed: 429");
474
475 let err = ContentLoopError::StorageError("disk full".to_string());
476 assert_eq!(err.to_string(), "Storage error: disk full");
477
478 let err = ContentLoopError::NetworkError("timeout".to_string());
479 assert_eq!(err.to_string(), "Network error: timeout");
480
481 let err = ContentLoopError::Other("unknown".to_string());
482 assert_eq!(err.to_string(), "unknown");
483 }
484}