1use 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::content::ContentGenerator;
10use tuitbot_core::storage::{self, approval_queue};
11
12use crate::account::{require_mutate, AccountContext};
13use crate::error::ApiError;
14use crate::state::AppState;
15
16async fn get_generator(
21 state: &AppState,
22 account_id: &str,
23) -> Result<Arc<ContentGenerator>, ApiError> {
24 state
25 .get_or_create_content_generator(account_id)
26 .await
27 .map_err(ApiError::BadRequest)
28}
29
30#[derive(Deserialize)]
35pub struct FeedQuery {
36 #[serde(default = "default_min_score")]
37 pub min_score: f64,
38 pub max_score: Option<f64>,
39 pub keyword: Option<String>,
40 #[serde(default = "default_feed_limit")]
41 pub limit: u32,
42}
43
44fn default_min_score() -> f64 {
45 50.0
46}
47fn default_feed_limit() -> u32 {
48 20
49}
50
51#[derive(Serialize)]
52pub struct DiscoveryTweet {
53 pub id: String,
54 pub author_username: String,
55 pub content: String,
56 pub relevance_score: f64,
57 pub matched_keyword: Option<String>,
58 pub like_count: i64,
59 pub retweet_count: i64,
60 pub reply_count: i64,
61 pub replied_to: bool,
62 pub discovered_at: String,
63}
64
65pub async fn feed(
66 State(state): State<Arc<AppState>>,
67 ctx: AccountContext,
68 Query(q): Query<FeedQuery>,
69) -> Result<Json<Vec<DiscoveryTweet>>, ApiError> {
70 let rows = storage::tweets::get_discovery_feed_filtered_for(
71 &state.db,
72 &ctx.account_id,
73 q.min_score,
74 q.max_score,
75 q.keyword.as_deref(),
76 q.limit,
77 )
78 .await?;
79
80 let tweets = rows
81 .into_iter()
82 .map(|t| DiscoveryTweet {
83 id: t.id,
84 author_username: t.author_username,
85 content: t.content,
86 relevance_score: t.relevance_score.unwrap_or(0.0),
87 matched_keyword: t.matched_keyword,
88 like_count: t.like_count,
89 retweet_count: t.retweet_count,
90 reply_count: t.reply_count,
91 replied_to: t.replied_to != 0,
92 discovered_at: t.discovered_at,
93 })
94 .collect();
95
96 Ok(Json(tweets))
97}
98
99pub async fn keywords(
104 State(state): State<Arc<AppState>>,
105 ctx: AccountContext,
106) -> Result<Json<Vec<String>>, ApiError> {
107 let kws = storage::tweets::get_distinct_keywords_for(&state.db, &ctx.account_id).await?;
108 Ok(Json(kws))
109}
110
111#[derive(Deserialize)]
116pub struct ComposeReplyRequest {
117 #[serde(default)]
118 pub mention_product: bool,
119}
120
121#[derive(Serialize)]
122pub struct ComposeReplyResponse {
123 pub content: String,
124 pub tweet_id: String,
125}
126
127pub async fn compose_reply(
128 State(state): State<Arc<AppState>>,
129 ctx: AccountContext,
130 Path(tweet_id): Path<String>,
131 Json(body): Json<ComposeReplyRequest>,
132) -> Result<Json<ComposeReplyResponse>, ApiError> {
133 let gen = get_generator(&state, &ctx.account_id).await?;
134
135 let tweet = storage::tweets::get_tweet_by_id_for(&state.db, &ctx.account_id, &tweet_id)
137 .await?
138 .ok_or_else(|| {
139 ApiError::NotFound(format!("Tweet {tweet_id} not found in discovered tweets"))
140 })?;
141
142 let output = gen
143 .generate_reply(&tweet.content, &tweet.author_username, body.mention_product)
144 .await
145 .map_err(|e| ApiError::Internal(e.to_string()))?;
146
147 Ok(Json(ComposeReplyResponse {
148 content: output.text,
149 tweet_id,
150 }))
151}
152
153#[derive(Deserialize)]
158pub struct QueueReplyRequest {
159 pub content: String,
160}
161
162pub async fn queue_reply(
163 State(state): State<Arc<AppState>>,
164 ctx: AccountContext,
165 Path(tweet_id): Path<String>,
166 Json(body): Json<QueueReplyRequest>,
167) -> Result<Json<Value>, ApiError> {
168 require_mutate(&ctx)?;
169
170 crate::routes::content::require_post_capable(&state, &ctx.account_id).await?;
172
173 if body.content.trim().is_empty() {
174 return Err(ApiError::BadRequest(
175 "content must not be empty".to_string(),
176 ));
177 }
178
179 let target_author = storage::tweets::get_tweet_by_id_for(&state.db, &ctx.account_id, &tweet_id)
181 .await?
182 .map(|t| t.author_username)
183 .unwrap_or_default();
184
185 let queue_id = approval_queue::enqueue_for(
186 &state.db,
187 &ctx.account_id,
188 "reply",
189 &tweet_id,
190 &target_author,
191 &body.content,
192 "", "", 0.0, "[]",
196 )
197 .await?;
198
199 storage::approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved")
201 .await?;
202
203 Ok(Json(json!({
204 "approval_queue_id": queue_id,
205 "tweet_id": tweet_id,
206 "status": "queued_for_posting"
207 })))
208}