tuitbot_server/routes/
discovery.rs1use std::sync::Arc;
4
5use axum::extract::{Path, Query, State};
6use axum::Json;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use tuitbot_core::storage::{self, approval_queue};
10
11use crate::error::ApiError;
12use crate::state::AppState;
13
14#[derive(Deserialize)]
19pub struct FeedQuery {
20 #[serde(default = "default_min_score")]
21 pub min_score: f64,
22 #[serde(default = "default_feed_limit")]
23 pub limit: u32,
24}
25
26fn default_min_score() -> f64 {
27 50.0
28}
29fn default_feed_limit() -> u32 {
30 20
31}
32
33#[derive(Serialize)]
34pub struct DiscoveryTweet {
35 pub id: String,
36 pub author_username: String,
37 pub content: String,
38 pub relevance_score: f64,
39 pub matched_keyword: Option<String>,
40 pub like_count: i64,
41 pub retweet_count: i64,
42 pub reply_count: i64,
43 pub replied_to: bool,
44 pub discovered_at: String,
45}
46
47pub async fn feed(
48 State(state): State<Arc<AppState>>,
49 Query(q): Query<FeedQuery>,
50) -> Result<Json<Vec<DiscoveryTweet>>, ApiError> {
51 let rows = storage::tweets::get_discovery_feed(&state.db, q.min_score, q.limit).await?;
52
53 let tweets = rows
54 .into_iter()
55 .map(|t| DiscoveryTweet {
56 id: t.id,
57 author_username: t.author_username,
58 content: t.content,
59 relevance_score: t.relevance_score.unwrap_or(0.0),
60 matched_keyword: t.matched_keyword,
61 like_count: t.like_count,
62 retweet_count: t.retweet_count,
63 reply_count: t.reply_count,
64 replied_to: t.replied_to != 0,
65 discovered_at: t.discovered_at,
66 })
67 .collect();
68
69 Ok(Json(tweets))
70}
71
72#[derive(Deserialize)]
77pub struct ComposeReplyRequest {
78 #[serde(default)]
79 pub mention_product: bool,
80}
81
82#[derive(Serialize)]
83pub struct ComposeReplyResponse {
84 pub content: String,
85 pub tweet_id: String,
86}
87
88pub async fn compose_reply(
89 State(state): State<Arc<AppState>>,
90 Path(tweet_id): Path<String>,
91 Json(body): Json<ComposeReplyRequest>,
92) -> Result<Json<ComposeReplyResponse>, ApiError> {
93 let gen = state
94 .content_generator
95 .as_ref()
96 .ok_or(ApiError::BadRequest(
97 "LLM not configured — set llm.provider and llm.api_key in config.toml".to_string(),
98 ))?;
99
100 let tweet = storage::tweets::get_tweet_by_id(&state.db, &tweet_id)
102 .await?
103 .ok_or_else(|| {
104 ApiError::NotFound(format!("Tweet {tweet_id} not found in discovered tweets"))
105 })?;
106
107 let output = gen
108 .generate_reply(&tweet.content, &tweet.author_username, body.mention_product)
109 .await
110 .map_err(|e| ApiError::Internal(e.to_string()))?;
111
112 Ok(Json(ComposeReplyResponse {
113 content: output.text,
114 tweet_id,
115 }))
116}
117
118#[derive(Deserialize)]
123pub struct QueueReplyRequest {
124 pub content: String,
125}
126
127pub async fn queue_reply(
128 State(state): State<Arc<AppState>>,
129 Path(tweet_id): Path<String>,
130 Json(body): Json<QueueReplyRequest>,
131) -> Result<Json<Value>, ApiError> {
132 if body.content.trim().is_empty() {
133 return Err(ApiError::BadRequest(
134 "content must not be empty".to_string(),
135 ));
136 }
137
138 let target_author = storage::tweets::get_tweet_by_id(&state.db, &tweet_id)
140 .await?
141 .map(|t| t.author_username)
142 .unwrap_or_default();
143
144 let queue_id = approval_queue::enqueue(
145 &state.db,
146 "reply",
147 &tweet_id,
148 &target_author,
149 &body.content,
150 "", "", 0.0, "[]",
154 )
155 .await?;
156
157 storage::approval_queue::update_status(&state.db, queue_id, "approved").await?;
159
160 Ok(Json(json!({
161 "approval_queue_id": queue_id,
162 "tweet_id": tweet_id,
163 "status": "queued_for_posting"
164 })))
165}