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
294/// Posts tweets directly to X (for thread reply chains).
295///
296/// Thread tweets bypass the posting queue because reply chain
297/// order must be maintained -- each tweet must reply to the previous.
298#[async_trait::async_trait]
299pub trait ThreadPoster: Send + Sync {
300    /// Post a standalone tweet. Returns the tweet ID.
301    async fn post_tweet(&self, content: &str) -> Result<String, ContentLoopError>;
302
303    /// Reply to a tweet. Returns the new tweet ID.
304    async fn reply_to_tweet(
305        &self,
306        in_reply_to: &str,
307        content: &str,
308    ) -> Result<String, ContentLoopError>;
309}
310
311// ============================================================================
312// Shared utilities
313// ============================================================================
314
315/// Tracks consecutive errors to prevent infinite retry loops.
316///
317/// If a loop encounters `max_consecutive` errors without a single
318/// success, it should pause for `pause_duration` before retrying.
319#[derive(Debug)]
320pub struct ConsecutiveErrorTracker {
321    count: u32,
322    max_consecutive: u32,
323    pause_duration: Duration,
324}
325
326impl ConsecutiveErrorTracker {
327    /// Create a new tracker.
328    ///
329    /// - `max_consecutive`: Number of consecutive errors before pausing.
330    /// - `pause_duration`: How long to pause after hitting the limit.
331    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    /// Record an error. Returns true if the loop should pause.
340    pub fn record_error(&mut self) -> bool {
341        self.count += 1;
342        self.count >= self.max_consecutive
343    }
344
345    /// Record a success, resetting the counter.
346    pub fn record_success(&mut self) {
347        self.count = 0;
348    }
349
350    /// Whether the loop should pause due to too many consecutive errors.
351    pub fn should_pause(&self) -> bool {
352        self.count >= self.max_consecutive
353    }
354
355    /// How long to pause.
356    pub fn pause_duration(&self) -> Duration {
357        self.pause_duration
358    }
359
360    /// Current consecutive error count.
361    pub fn count(&self) -> u32 {
362        self.count
363    }
364
365    /// Reset the counter.
366    pub fn reset(&mut self) {
367        self.count = 0;
368    }
369}
370
371/// Compute a backoff duration for rate limit errors.
372///
373/// Uses the `retry_after` hint if available, otherwise exponential
374/// backoff starting at 60s, capped at 15 minutes.
375pub 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)) // cap at 15 minutes
382    }
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()); // 1
396        assert!(!tracker.record_error()); // 2
397        assert!(tracker.record_error()); // 3 -- should pause
398        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}