Skip to main content

tuitbot_core/automation/
loop_helpers.rs

1//! Shared types, traits, and helpers for automation loops.
2//!
3//! Defines port traits that decouple loop logic from concrete
4//! implementations (X API, LLM, storage, safety). When all work
5//! packages are merged, adapter implementations bridge these traits
6//! to the actual types.
7
8use std::fmt;
9use std::time::Duration;
10
11// ============================================================================
12// WP08 types: Mentions + Discovery loops
13// ============================================================================
14
15/// Tweet data as seen by automation loop logic.
16///
17/// A common representation used by both mentions and discovery loops,
18/// decoupled from any specific API response type.
19#[derive(Debug, Clone)]
20pub struct LoopTweet {
21    /// Unique tweet ID.
22    pub id: String,
23    /// Tweet text content.
24    pub text: String,
25    /// Author's user ID.
26    pub author_id: String,
27    /// Author's username (without @).
28    pub author_username: String,
29    /// Author's follower count.
30    pub author_followers: u64,
31    /// ISO-8601 creation timestamp.
32    pub created_at: String,
33    /// Number of likes.
34    pub likes: u64,
35    /// Number of retweets.
36    pub retweets: u64,
37    /// Number of replies.
38    pub replies: u64,
39}
40
41/// Result of scoring a tweet for reply-worthiness.
42#[derive(Debug, Clone)]
43pub struct ScoreResult {
44    /// Total score (0-100).
45    pub total: f32,
46    /// Whether the score meets the configured threshold.
47    pub meets_threshold: bool,
48    /// Keywords that matched in the tweet.
49    pub matched_keywords: Vec<String>,
50}
51
52/// Errors that can occur in mentions/discovery automation loops.
53///
54/// Wraps specific error categories to enable appropriate handling
55/// (e.g., back off on rate limit, skip on LLM failure, refresh on auth expiry).
56#[derive(Debug)]
57pub enum LoopError {
58    /// X API rate limit hit.
59    RateLimited {
60        /// Seconds to wait before retrying, if known.
61        retry_after: Option<u64>,
62    },
63    /// OAuth token expired and needs refresh.
64    AuthExpired,
65    /// LLM content generation failed.
66    LlmFailure(String),
67    /// Network-level error.
68    NetworkError(String),
69    /// Database/storage error.
70    StorageError(String),
71    /// Any other error.
72    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// ============================================================================
92// WP09 types: Content + Thread loops
93// ============================================================================
94
95/// Errors that can occur in the content/thread automation loops.
96#[derive(Debug)]
97pub enum ContentLoopError {
98    /// LLM generation failed.
99    LlmFailure(String),
100    /// Posting to X failed.
101    PostFailed(String),
102    /// Storage/database error.
103    StorageError(String),
104    /// Network error.
105    NetworkError(String),
106    /// Other error.
107    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// ============================================================================
125// WP08 port traits: Mentions + Discovery loops
126// ============================================================================
127
128/// Port for fetching @-mentions from X API.
129#[async_trait::async_trait]
130pub trait MentionsFetcher: Send + Sync {
131    /// Fetch mentions since the given ID. Returns newest first.
132    async fn get_mentions(&self, since_id: Option<&str>) -> Result<Vec<LoopTweet>, LoopError>;
133}
134
135/// Port for searching tweets by keyword.
136#[async_trait::async_trait]
137pub trait TweetSearcher: Send + Sync {
138    /// Search for tweets matching the query.
139    async fn search_tweets(&self, query: &str) -> Result<Vec<LoopTweet>, LoopError>;
140}
141
142/// Port for generating reply content via LLM.
143#[async_trait::async_trait]
144pub trait ReplyGenerator: Send + Sync {
145    /// Generate a reply to the given tweet.
146    ///
147    /// When `mention_product` is true, the reply may reference the product.
148    /// When false, the reply must be purely helpful with no product mention.
149    async fn generate_reply(
150        &self,
151        tweet_text: &str,
152        author: &str,
153        mention_product: bool,
154    ) -> Result<String, LoopError>;
155}
156
157/// Port for safety checks (rate limits and dedup).
158#[async_trait::async_trait]
159pub trait SafetyChecker: Send + Sync {
160    /// Check if we can reply (under daily rate limit).
161    async fn can_reply(&self) -> bool;
162
163    /// Check if we've already replied to this tweet.
164    async fn has_replied_to(&self, tweet_id: &str) -> bool;
165
166    /// Record a reply for dedup and rate limit tracking.
167    async fn record_reply(&self, tweet_id: &str, reply_content: &str) -> Result<(), LoopError>;
168}
169
170/// Port for scoring tweets.
171pub trait TweetScorer: Send + Sync {
172    /// Score a tweet for reply-worthiness.
173    fn score(&self, tweet: &LoopTweet) -> ScoreResult;
174}
175
176/// Port for persisting loop state (since_id, discovered tweets, action log).
177#[async_trait::async_trait]
178pub trait LoopStorage: Send + Sync {
179    /// Get a persisted cursor value (e.g., since_id for mentions).
180    async fn get_cursor(&self, key: &str) -> Result<Option<String>, LoopError>;
181
182    /// Set a persisted cursor value.
183    async fn set_cursor(&self, key: &str, value: &str) -> Result<(), LoopError>;
184
185    /// Check if a discovered tweet already exists (dedup by tweet ID).
186    async fn tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError>;
187
188    /// Store a discovered tweet with its score and matched keyword.
189    async fn store_discovered_tweet(
190        &self,
191        tweet: &LoopTweet,
192        score: f32,
193        keyword: &str,
194    ) -> Result<(), LoopError>;
195
196    /// Log an action (for audit trail and status reporting).
197    async fn log_action(
198        &self,
199        action_type: &str,
200        status: &str,
201        message: &str,
202    ) -> Result<(), LoopError>;
203}
204
205/// Port for sending post actions to the posting queue.
206#[async_trait::async_trait]
207pub trait PostSender: Send + Sync {
208    /// Send a reply to a tweet through the posting queue.
209    async fn send_reply(&self, tweet_id: &str, content: &str) -> Result<(), LoopError>;
210}
211
212// ============================================================================
213// WP09 port traits: Content + Thread loops
214// ============================================================================
215
216/// Queries top-performing topics for epsilon-greedy topic selection.
217#[async_trait::async_trait]
218pub trait TopicScorer: Send + Sync {
219    /// Get the top-performing topics, ordered by score descending.
220    async fn get_top_topics(&self, limit: u32) -> Result<Vec<String>, ContentLoopError>;
221}
222
223/// Generates individual tweets on a given topic.
224#[async_trait::async_trait]
225pub trait TweetGenerator: Send + Sync {
226    /// Generate an educational tweet on the given topic.
227    async fn generate_tweet(&self, topic: &str) -> Result<String, ContentLoopError>;
228}
229
230/// Checks safety limits for content posting.
231#[async_trait::async_trait]
232pub trait ContentSafety: Send + Sync {
233    /// Check if a tweet can be posted (daily limit not reached).
234    async fn can_post_tweet(&self) -> bool;
235    /// Check if a thread can be posted (weekly limit not reached).
236    async fn can_post_thread(&self) -> bool;
237}
238
239/// Storage operations for content and thread loops.
240#[async_trait::async_trait]
241pub trait ContentStorage: Send + Sync {
242    /// Get the timestamp of the most recent posted tweet.
243    async fn last_tweet_time(
244        &self,
245    ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError>;
246
247    /// Get the timestamp of the most recent posted thread.
248    async fn last_thread_time(
249        &self,
250    ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError>;
251
252    /// Get the timestamps of all tweets posted today (for slot-based scheduling).
253    async fn todays_tweet_times(
254        &self,
255    ) -> Result<Vec<chrono::DateTime<chrono::Utc>>, ContentLoopError>;
256
257    /// Post a tweet (sends to posting queue and records in DB).
258    async fn post_tweet(&self, topic: &str, content: &str) -> Result<(), ContentLoopError>;
259
260    /// Create a thread record in the database. Returns the thread ID.
261    async fn create_thread(
262        &self,
263        topic: &str,
264        tweet_count: usize,
265    ) -> Result<String, ContentLoopError>;
266
267    /// Update thread status (pending, posting, sent, partial).
268    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    /// Record a thread tweet (position in reply chain).
277    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    /// Log an action to the audit trail.
286    async fn log_action(
287        &self,
288        action_type: &str,
289        status: &str,
290        message: &str,
291    ) -> Result<(), ContentLoopError>;
292
293    /// Check for scheduled content that is due for posting.
294    ///
295    /// Returns the next due item (content, id) or None.
296    async fn next_scheduled_item(&self) -> Result<Option<(i64, String, String)>, ContentLoopError> {
297        // Default: no scheduled content support.
298        Ok(None)
299    }
300
301    /// Mark a scheduled content item as posted.
302    async fn mark_scheduled_posted(
303        &self,
304        _id: i64,
305        _tweet_id: Option<&str>,
306    ) -> Result<(), ContentLoopError> {
307        Ok(())
308    }
309}
310
311/// Posts tweets directly to X (for thread reply chains).
312///
313/// Thread tweets bypass the posting queue because reply chain
314/// order must be maintained -- each tweet must reply to the previous.
315#[async_trait::async_trait]
316pub trait ThreadPoster: Send + Sync {
317    /// Post a standalone tweet. Returns the tweet ID.
318    async fn post_tweet(&self, content: &str) -> Result<String, ContentLoopError>;
319
320    /// Reply to a tweet. Returns the new tweet ID.
321    async fn reply_to_tweet(
322        &self,
323        in_reply_to: &str,
324        content: &str,
325    ) -> Result<String, ContentLoopError>;
326}
327
328// ============================================================================
329// Shared utilities
330// ============================================================================
331
332/// Tracks consecutive errors to prevent infinite retry loops.
333///
334/// If a loop encounters `max_consecutive` errors without a single
335/// success, it should pause for `pause_duration` before retrying.
336#[derive(Debug)]
337pub struct ConsecutiveErrorTracker {
338    count: u32,
339    max_consecutive: u32,
340    pause_duration: Duration,
341}
342
343impl ConsecutiveErrorTracker {
344    /// Create a new tracker.
345    ///
346    /// - `max_consecutive`: Number of consecutive errors before pausing.
347    /// - `pause_duration`: How long to pause after hitting the limit.
348    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    /// Record an error. Returns true if the loop should pause.
357    pub fn record_error(&mut self) -> bool {
358        self.count += 1;
359        self.count >= self.max_consecutive
360    }
361
362    /// Record a success, resetting the counter.
363    pub fn record_success(&mut self) {
364        self.count = 0;
365    }
366
367    /// Whether the loop should pause due to too many consecutive errors.
368    pub fn should_pause(&self) -> bool {
369        self.count >= self.max_consecutive
370    }
371
372    /// How long to pause.
373    pub fn pause_duration(&self) -> Duration {
374        self.pause_duration
375    }
376
377    /// Current consecutive error count.
378    pub fn count(&self) -> u32 {
379        self.count
380    }
381
382    /// Reset the counter.
383    pub fn reset(&mut self) {
384        self.count = 0;
385    }
386}
387
388/// Compute a backoff duration for rate limit errors.
389///
390/// Uses the `retry_after` hint if available, otherwise exponential
391/// backoff starting at 60s, capped at 15 minutes.
392pub 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)) // cap at 15 minutes
399    }
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()); // 1
413        assert!(!tracker.record_error()); // 2
414        assert!(tracker.record_error()); // 3 -- should pause
415        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}