use std::fmt;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct LoopTweet {
pub id: String,
pub text: String,
pub author_id: String,
pub author_username: String,
pub author_followers: u64,
pub created_at: String,
pub likes: u64,
pub retweets: u64,
pub replies: u64,
}
#[derive(Debug, Clone)]
pub struct ScoreResult {
pub total: f32,
pub meets_threshold: bool,
pub matched_keywords: Vec<String>,
}
#[derive(Debug)]
pub enum LoopError {
RateLimited {
retry_after: Option<u64>,
},
AuthExpired,
LlmFailure(String),
NetworkError(String),
StorageError(String),
Other(String),
}
impl fmt::Display for LoopError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LoopError::RateLimited { retry_after } => match retry_after {
Some(secs) => write!(f, "rate limited, retry after {secs}s"),
None => write!(f, "rate limited"),
},
LoopError::AuthExpired => write!(f, "authentication expired"),
LoopError::LlmFailure(msg) => write!(f, "LLM failure: {msg}"),
LoopError::NetworkError(msg) => write!(f, "network error: {msg}"),
LoopError::StorageError(msg) => write!(f, "storage error: {msg}"),
LoopError::Other(msg) => write!(f, "{msg}"),
}
}
}
#[derive(Debug)]
pub enum ContentLoopError {
LlmFailure(String),
PostFailed(String),
StorageError(String),
NetworkError(String),
Other(String),
}
impl fmt::Display for ContentLoopError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LlmFailure(msg) => write!(f, "LLM failure: {msg}"),
Self::PostFailed(msg) => write!(f, "Post failed: {msg}"),
Self::StorageError(msg) => write!(f, "Storage error: {msg}"),
Self::NetworkError(msg) => write!(f, "Network error: {msg}"),
Self::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for ContentLoopError {}
#[async_trait::async_trait]
pub trait MentionsFetcher: Send + Sync {
async fn get_mentions(&self, since_id: Option<&str>) -> Result<Vec<LoopTweet>, LoopError>;
}
#[async_trait::async_trait]
pub trait TweetSearcher: Send + Sync {
async fn search_tweets(&self, query: &str) -> Result<Vec<LoopTweet>, LoopError>;
}
#[derive(Debug, Clone)]
pub struct ReplyOutput {
pub text: String,
pub vault_citations: Vec<crate::context::retrieval::VaultCitation>,
}
#[async_trait::async_trait]
pub trait ReplyGenerator: Send + Sync {
async fn generate_reply(
&self,
tweet_text: &str,
author: &str,
mention_product: bool,
) -> Result<String, LoopError>;
async fn generate_reply_with_rag(
&self,
tweet_text: &str,
author: &str,
mention_product: bool,
) -> Result<ReplyOutput, LoopError> {
let text = self
.generate_reply(tweet_text, author, mention_product)
.await?;
Ok(ReplyOutput {
text,
vault_citations: vec![],
})
}
}
#[async_trait::async_trait]
pub trait SafetyChecker: Send + Sync {
async fn can_reply(&self) -> bool;
async fn has_replied_to(&self, tweet_id: &str) -> bool;
async fn record_reply(&self, tweet_id: &str, reply_content: &str) -> Result<(), LoopError>;
}
pub trait TweetScorer: Send + Sync {
fn score(&self, tweet: &LoopTweet) -> ScoreResult;
}
#[async_trait::async_trait]
pub trait LoopStorage: Send + Sync {
async fn get_cursor(&self, key: &str) -> Result<Option<String>, LoopError>;
async fn set_cursor(&self, key: &str, value: &str) -> Result<(), LoopError>;
async fn tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError>;
async fn store_discovered_tweet(
&self,
tweet: &LoopTweet,
score: f32,
keyword: &str,
) -> Result<(), LoopError>;
async fn log_action(
&self,
action_type: &str,
status: &str,
message: &str,
) -> Result<(), LoopError>;
}
#[async_trait::async_trait]
pub trait PostSender: Send + Sync {
async fn send_reply(&self, tweet_id: &str, content: &str) -> Result<(), LoopError>;
}
#[async_trait::async_trait]
pub trait TopicScorer: Send + Sync {
async fn get_top_topics(&self, limit: u32) -> Result<Vec<String>, ContentLoopError>;
}
#[async_trait::async_trait]
pub trait TweetGenerator: Send + Sync {
async fn generate_tweet(&self, topic: &str) -> Result<String, ContentLoopError>;
}
#[async_trait::async_trait]
pub trait ContentSafety: Send + Sync {
async fn can_post_tweet(&self) -> bool;
async fn can_post_thread(&self) -> bool;
}
#[async_trait::async_trait]
pub trait ContentStorage: Send + Sync {
async fn last_tweet_time(
&self,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError>;
async fn last_thread_time(
&self,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError>;
async fn todays_tweet_times(
&self,
) -> Result<Vec<chrono::DateTime<chrono::Utc>>, ContentLoopError>;
async fn post_tweet(&self, topic: &str, content: &str) -> Result<(), ContentLoopError>;
async fn create_thread(
&self,
topic: &str,
tweet_count: usize,
) -> Result<String, ContentLoopError>;
async fn update_thread_status(
&self,
thread_id: &str,
status: &str,
tweet_count: usize,
root_tweet_id: Option<&str>,
) -> Result<(), ContentLoopError>;
async fn store_thread_tweet(
&self,
thread_id: &str,
position: usize,
tweet_id: &str,
content: &str,
) -> Result<(), ContentLoopError>;
async fn log_action(
&self,
action_type: &str,
status: &str,
message: &str,
) -> Result<(), ContentLoopError>;
async fn next_scheduled_item(&self) -> Result<Option<(i64, String, String)>, ContentLoopError> {
Ok(None)
}
async fn mark_scheduled_posted(
&self,
_id: i64,
_tweet_id: Option<&str>,
) -> Result<(), ContentLoopError> {
Ok(())
}
async fn mark_failed_permanent(
&self,
thread_id: &str,
error: &str,
) -> Result<(), ContentLoopError> {
let _ = (thread_id, error);
Ok(())
}
async fn increment_retry(&self, thread_id: &str, error: &str) -> Result<u32, ContentLoopError> {
let _ = (thread_id, error);
Ok(0)
}
}
#[async_trait::async_trait]
pub trait ThreadPoster: Send + Sync {
async fn post_tweet(&self, content: &str) -> Result<String, ContentLoopError>;
async fn reply_to_tweet(
&self,
in_reply_to: &str,
content: &str,
) -> Result<String, ContentLoopError>;
}
#[derive(Debug)]
pub struct ConsecutiveErrorTracker {
count: u32,
max_consecutive: u32,
pause_duration: Duration,
}
impl ConsecutiveErrorTracker {
pub fn new(max_consecutive: u32, pause_duration: Duration) -> Self {
Self {
count: 0,
max_consecutive,
pause_duration,
}
}
pub fn record_error(&mut self) -> bool {
self.count += 1;
self.count >= self.max_consecutive
}
pub fn record_success(&mut self) {
self.count = 0;
}
pub fn should_pause(&self) -> bool {
self.count >= self.max_consecutive
}
pub fn pause_duration(&self) -> Duration {
self.pause_duration
}
pub fn count(&self) -> u32 {
self.count
}
pub fn reset(&mut self) {
self.count = 0;
}
}
pub fn rate_limit_backoff(retry_after: Option<u64>, attempt: u32) -> Duration {
if let Some(secs) = retry_after {
Duration::from_secs(secs)
} else {
let base = 60u64;
let exp = base.saturating_mul(2u64.saturating_pow(attempt));
Duration::from_secs(exp.min(900)) }
}
pub fn is_transient_error(error_msg: &str) -> bool {
let lower = error_msg.to_lowercase();
lower.contains("429")
|| lower.contains("500")
|| lower.contains("502")
|| lower.contains("503")
|| lower.contains("504")
|| lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("connection reset")
|| lower.contains("connection refused")
|| lower.contains("try again")
}
pub fn thread_retry_backoff(retry_count: u32) -> Duration {
let base_secs = 30u64;
let exp = base_secs.saturating_mul(2u64.saturating_pow(retry_count));
Duration::from_secs(exp.min(300)) }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_tracker_records_errors() {
let mut tracker = ConsecutiveErrorTracker::new(3, Duration::from_secs(300));
assert!(!tracker.should_pause());
assert_eq!(tracker.count(), 0);
assert!(!tracker.record_error()); assert!(!tracker.record_error()); assert!(tracker.record_error()); assert!(tracker.should_pause());
assert_eq!(tracker.count(), 3);
}
#[test]
fn error_tracker_resets_on_success() {
let mut tracker = ConsecutiveErrorTracker::new(3, Duration::from_secs(300));
tracker.record_error();
tracker.record_error();
tracker.record_success();
assert_eq!(tracker.count(), 0);
assert!(!tracker.should_pause());
}
#[test]
fn error_tracker_pause_duration() {
let tracker = ConsecutiveErrorTracker::new(5, Duration::from_secs(120));
assert_eq!(tracker.pause_duration(), Duration::from_secs(120));
}
#[test]
fn rate_limit_backoff_with_retry_after() {
assert_eq!(rate_limit_backoff(Some(30), 0), Duration::from_secs(30));
assert_eq!(rate_limit_backoff(Some(120), 5), Duration::from_secs(120));
}
#[test]
fn rate_limit_backoff_exponential() {
assert_eq!(rate_limit_backoff(None, 0), Duration::from_secs(60));
assert_eq!(rate_limit_backoff(None, 1), Duration::from_secs(120));
assert_eq!(rate_limit_backoff(None, 2), Duration::from_secs(240));
}
#[test]
fn rate_limit_backoff_capped_at_15_minutes() {
assert_eq!(rate_limit_backoff(None, 10), Duration::from_secs(900));
}
#[test]
fn loop_error_display() {
let err = LoopError::RateLimited {
retry_after: Some(30),
};
assert_eq!(err.to_string(), "rate limited, retry after 30s");
let err = LoopError::AuthExpired;
assert_eq!(err.to_string(), "authentication expired");
let err = LoopError::LlmFailure("timeout".to_string());
assert_eq!(err.to_string(), "LLM failure: timeout");
}
#[test]
fn loop_tweet_debug() {
let tweet = LoopTweet {
id: "123".to_string(),
text: "hello".to_string(),
author_id: "uid_123".to_string(),
author_username: "user".to_string(),
author_followers: 1000,
created_at: "2026-01-01T00:00:00Z".to_string(),
likes: 10,
retweets: 2,
replies: 1,
};
let debug = format!("{tweet:?}");
assert!(debug.contains("123"));
}
#[test]
fn reply_output_creation() {
let output = ReplyOutput {
text: "Great point!".to_string(),
vault_citations: vec![],
};
assert_eq!(output.text, "Great point!");
assert!(output.vault_citations.is_empty());
}
#[test]
fn content_loop_error_display() {
let err = ContentLoopError::LlmFailure("model down".to_string());
assert_eq!(err.to_string(), "LLM failure: model down");
let err = ContentLoopError::PostFailed("429".to_string());
assert_eq!(err.to_string(), "Post failed: 429");
let err = ContentLoopError::StorageError("disk full".to_string());
assert_eq!(err.to_string(), "Storage error: disk full");
let err = ContentLoopError::NetworkError("timeout".to_string());
assert_eq!(err.to_string(), "Network error: timeout");
let err = ContentLoopError::Other("unknown".to_string());
assert_eq!(err.to_string(), "unknown");
}
#[test]
fn error_tracker_manual_reset() {
let mut tracker = ConsecutiveErrorTracker::new(3, Duration::from_secs(300));
tracker.record_error();
tracker.record_error();
assert_eq!(tracker.count(), 2);
tracker.reset();
assert_eq!(tracker.count(), 0);
assert!(!tracker.should_pause());
}
#[test]
fn loop_error_display_remaining_variants() {
let err = LoopError::RateLimited { retry_after: None };
assert_eq!(err.to_string(), "rate limited");
let err = LoopError::NetworkError("timeout".to_string());
assert_eq!(err.to_string(), "network error: timeout");
let err = LoopError::StorageError("disk full".to_string());
assert_eq!(err.to_string(), "storage error: disk full");
let err = LoopError::Other("unknown".to_string());
assert_eq!(err.to_string(), "unknown");
}
#[test]
fn rate_limit_backoff_very_high_attempt() {
assert_eq!(rate_limit_backoff(None, 100), Duration::from_secs(900));
}
#[test]
fn consecutive_error_tracker_new_state() {
let tracker = ConsecutiveErrorTracker::new(5, Duration::from_secs(60));
assert_eq!(tracker.count(), 0);
assert!(!tracker.should_pause());
assert_eq!(tracker.pause_duration(), Duration::from_secs(60));
}
#[test]
fn score_result_debug() {
let sr = ScoreResult {
total: 75.0,
meets_threshold: true,
matched_keywords: vec!["rust".to_string()],
};
let debug = format!("{sr:?}");
assert!(debug.contains("75"));
assert!(debug.contains("rust"));
}
}