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 async fn next_scheduled_item(&self) -> Result<Option<(i64, String, String)>, ContentLoopError> {
297 Ok(None)
299 }
300
301 async fn mark_scheduled_posted(
303 &self,
304 _id: i64,
305 _tweet_id: Option<&str>,
306 ) -> Result<(), ContentLoopError> {
307 Ok(())
308 }
309}
310
311#[async_trait::async_trait]
316pub trait ThreadPoster: Send + Sync {
317 async fn post_tweet(&self, content: &str) -> Result<String, ContentLoopError>;
319
320 async fn reply_to_tweet(
322 &self,
323 in_reply_to: &str,
324 content: &str,
325 ) -> Result<String, ContentLoopError>;
326}
327
328#[derive(Debug)]
337pub struct ConsecutiveErrorTracker {
338 count: u32,
339 max_consecutive: u32,
340 pause_duration: Duration,
341}
342
343impl ConsecutiveErrorTracker {
344 pub fn new(max_consecutive: u32, pause_duration: Duration) -> Self {
349 Self {
350 count: 0,
351 max_consecutive,
352 pause_duration,
353 }
354 }
355
356 pub fn record_error(&mut self) -> bool {
358 self.count += 1;
359 self.count >= self.max_consecutive
360 }
361
362 pub fn record_success(&mut self) {
364 self.count = 0;
365 }
366
367 pub fn should_pause(&self) -> bool {
369 self.count >= self.max_consecutive
370 }
371
372 pub fn pause_duration(&self) -> Duration {
374 self.pause_duration
375 }
376
377 pub fn count(&self) -> u32 {
379 self.count
380 }
381
382 pub fn reset(&mut self) {
384 self.count = 0;
385 }
386}
387
388pub fn rate_limit_backoff(retry_after: Option<u64>, attempt: u32) -> Duration {
393 if let Some(secs) = retry_after {
394 Duration::from_secs(secs)
395 } else {
396 let base = 60u64;
397 let exp = base.saturating_mul(2u64.saturating_pow(attempt));
398 Duration::from_secs(exp.min(900)) }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn error_tracker_records_errors() {
408 let mut tracker = ConsecutiveErrorTracker::new(3, Duration::from_secs(300));
409 assert!(!tracker.should_pause());
410 assert_eq!(tracker.count(), 0);
411
412 assert!(!tracker.record_error()); assert!(!tracker.record_error()); assert!(tracker.record_error()); assert!(tracker.should_pause());
416 assert_eq!(tracker.count(), 3);
417 }
418
419 #[test]
420 fn error_tracker_resets_on_success() {
421 let mut tracker = ConsecutiveErrorTracker::new(3, Duration::from_secs(300));
422 tracker.record_error();
423 tracker.record_error();
424 tracker.record_success();
425 assert_eq!(tracker.count(), 0);
426 assert!(!tracker.should_pause());
427 }
428
429 #[test]
430 fn error_tracker_pause_duration() {
431 let tracker = ConsecutiveErrorTracker::new(5, Duration::from_secs(120));
432 assert_eq!(tracker.pause_duration(), Duration::from_secs(120));
433 }
434
435 #[test]
436 fn rate_limit_backoff_with_retry_after() {
437 assert_eq!(rate_limit_backoff(Some(30), 0), Duration::from_secs(30));
438 assert_eq!(rate_limit_backoff(Some(120), 5), Duration::from_secs(120));
439 }
440
441 #[test]
442 fn rate_limit_backoff_exponential() {
443 assert_eq!(rate_limit_backoff(None, 0), Duration::from_secs(60));
444 assert_eq!(rate_limit_backoff(None, 1), Duration::from_secs(120));
445 assert_eq!(rate_limit_backoff(None, 2), Duration::from_secs(240));
446 }
447
448 #[test]
449 fn rate_limit_backoff_capped_at_15_minutes() {
450 assert_eq!(rate_limit_backoff(None, 10), Duration::from_secs(900));
451 }
452
453 #[test]
454 fn loop_error_display() {
455 let err = LoopError::RateLimited {
456 retry_after: Some(30),
457 };
458 assert_eq!(err.to_string(), "rate limited, retry after 30s");
459
460 let err = LoopError::AuthExpired;
461 assert_eq!(err.to_string(), "authentication expired");
462
463 let err = LoopError::LlmFailure("timeout".to_string());
464 assert_eq!(err.to_string(), "LLM failure: timeout");
465 }
466
467 #[test]
468 fn loop_tweet_debug() {
469 let tweet = LoopTweet {
470 id: "123".to_string(),
471 text: "hello".to_string(),
472 author_id: "uid_123".to_string(),
473 author_username: "user".to_string(),
474 author_followers: 1000,
475 created_at: "2026-01-01T00:00:00Z".to_string(),
476 likes: 10,
477 retweets: 2,
478 replies: 1,
479 };
480 let debug = format!("{tweet:?}");
481 assert!(debug.contains("123"));
482 }
483
484 #[test]
485 fn content_loop_error_display() {
486 let err = ContentLoopError::LlmFailure("model down".to_string());
487 assert_eq!(err.to_string(), "LLM failure: model down");
488
489 let err = ContentLoopError::PostFailed("429".to_string());
490 assert_eq!(err.to_string(), "Post failed: 429");
491
492 let err = ContentLoopError::StorageError("disk full".to_string());
493 assert_eq!(err.to_string(), "Storage error: disk full");
494
495 let err = ContentLoopError::NetworkError("timeout".to_string());
496 assert_eq!(err.to_string(), "Network error: timeout");
497
498 let err = ContentLoopError::Other("unknown".to_string());
499 assert_eq!(err.to_string(), "unknown");
500 }
501}