tuitbot_core/storage/
replies.rs1use super::DbPool;
7use crate::error::StorageError;
8
9#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
11pub struct ReplySent {
12 pub id: i64,
14 pub target_tweet_id: String,
16 pub reply_tweet_id: Option<String>,
18 pub reply_content: String,
20 pub llm_provider: Option<String>,
22 pub llm_model: Option<String>,
24 pub created_at: String,
26 pub status: String,
28 pub error_message: Option<String>,
30}
31
32pub async fn insert_reply(pool: &DbPool, reply: &ReplySent) -> Result<i64, StorageError> {
34 let result = sqlx::query(
35 "INSERT INTO replies_sent \
36 (target_tweet_id, reply_tweet_id, reply_content, llm_provider, llm_model, \
37 created_at, status, error_message) \
38 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
39 )
40 .bind(&reply.target_tweet_id)
41 .bind(&reply.reply_tweet_id)
42 .bind(&reply.reply_content)
43 .bind(&reply.llm_provider)
44 .bind(&reply.llm_model)
45 .bind(&reply.created_at)
46 .bind(&reply.status)
47 .bind(&reply.error_message)
48 .execute(pool)
49 .await
50 .map_err(|e| StorageError::Query { source: e })?;
51
52 Ok(result.last_insert_rowid())
53}
54
55pub async fn get_replies_since(pool: &DbPool, since: &str) -> Result<Vec<ReplySent>, StorageError> {
57 sqlx::query_as::<_, ReplySent>(
58 "SELECT * FROM replies_sent WHERE created_at >= ? ORDER BY created_at ASC",
59 )
60 .bind(since)
61 .fetch_all(pool)
62 .await
63 .map_err(|e| StorageError::Query { source: e })
64}
65
66pub async fn has_replied_to(pool: &DbPool, tweet_id: &str) -> Result<bool, StorageError> {
68 let row: (i64,) =
69 sqlx::query_as("SELECT EXISTS(SELECT 1 FROM replies_sent WHERE target_tweet_id = ?)")
70 .bind(tweet_id)
71 .fetch_one(pool)
72 .await
73 .map_err(|e| StorageError::Query { source: e })?;
74
75 Ok(row.0 == 1)
76}
77
78pub async fn get_recent_reply_contents(
80 pool: &DbPool,
81 limit: i64,
82) -> Result<Vec<String>, StorageError> {
83 let rows: Vec<(String,)> =
84 sqlx::query_as("SELECT reply_content FROM replies_sent ORDER BY created_at DESC LIMIT ?")
85 .bind(limit)
86 .fetch_all(pool)
87 .await
88 .map_err(|e| StorageError::Query { source: e })?;
89
90 Ok(rows.into_iter().map(|r| r.0).collect())
91}
92
93pub async fn count_replies_today(pool: &DbPool) -> Result<i64, StorageError> {
95 let row: (i64,) =
96 sqlx::query_as("SELECT COUNT(*) FROM replies_sent WHERE date(created_at) = date('now')")
97 .fetch_one(pool)
98 .await
99 .map_err(|e| StorageError::Query { source: e })?;
100
101 Ok(row.0)
102}
103
104pub async fn get_recent_replies(
106 pool: &DbPool,
107 limit: u32,
108 offset: u32,
109) -> Result<Vec<ReplySent>, StorageError> {
110 sqlx::query_as::<_, ReplySent>(
111 "SELECT * FROM replies_sent ORDER BY created_at DESC LIMIT ? OFFSET ?",
112 )
113 .bind(limit)
114 .bind(offset)
115 .fetch_all(pool)
116 .await
117 .map_err(|e| StorageError::Query { source: e })
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::storage::init_test_db;
124
125 fn sample_reply(target_id: &str) -> ReplySent {
126 ReplySent {
127 id: 0, target_tweet_id: target_id.to_string(),
129 reply_tweet_id: Some("reply_123".to_string()),
130 reply_content: "Great point! Here's my take...".to_string(),
131 llm_provider: Some("openai".to_string()),
132 llm_model: Some("gpt-4o-mini".to_string()),
133 created_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
134 status: "sent".to_string(),
135 error_message: None,
136 }
137 }
138
139 #[tokio::test]
140 async fn insert_and_check_reply() {
141 let pool = init_test_db().await.expect("init db");
142 let reply = sample_reply("tweet_abc");
143
144 let id = insert_reply(&pool, &reply).await.expect("insert");
145 assert!(id > 0);
146
147 assert!(has_replied_to(&pool, "tweet_abc").await.expect("check"));
148 assert!(!has_replied_to(&pool, "tweet_xyz").await.expect("check"));
149 }
150
151 #[tokio::test]
152 async fn count_replies_today_works() {
153 let pool = init_test_db().await.expect("init db");
154 let reply = sample_reply("tweet_count");
155
156 insert_reply(&pool, &reply).await.expect("insert");
157 let count = count_replies_today(&pool).await.expect("count");
158 assert_eq!(count, 1);
159 }
160
161 #[tokio::test]
162 async fn get_recent_contents() {
163 let pool = init_test_db().await.expect("init db");
164
165 let mut r1 = sample_reply("t1");
166 r1.reply_content = "Reply one".to_string();
167 let mut r2 = sample_reply("t2");
168 r2.reply_content = "Reply two".to_string();
169
170 insert_reply(&pool, &r1).await.expect("ins1");
171 insert_reply(&pool, &r2).await.expect("ins2");
172
173 let contents = get_recent_reply_contents(&pool, 5).await.expect("get");
174 assert_eq!(contents.len(), 2);
175 }
176
177 #[tokio::test]
178 async fn get_replies_since_filters() {
179 let pool = init_test_db().await.expect("init db");
180 let reply = sample_reply("tweet_since");
181
182 insert_reply(&pool, &reply).await.expect("insert");
183
184 let all = get_replies_since(&pool, "2000-01-01T00:00:00Z")
185 .await
186 .expect("get");
187 assert_eq!(all.len(), 1);
188
189 let none = get_replies_since(&pool, "2099-01-01T00:00:00Z")
190 .await
191 .expect("get");
192 assert!(none.is_empty());
193 }
194}