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 let generators = state.content_generators.lock().await;
25 generators
26 .get(account_id)
27 .cloned()
28 .ok_or(ApiError::BadRequest(
29 "LLM not configured — set llm.provider and llm.api_key in config.toml".to_string(),
30 ))
31}
32
33#[derive(Deserialize)]
38pub struct FeedQuery {
39 #[serde(default = "default_min_score")]
40 pub min_score: f64,
41 pub max_score: Option<f64>,
42 pub keyword: Option<String>,
43 #[serde(default = "default_feed_limit")]
44 pub limit: u32,
45}
46
47fn default_min_score() -> f64 {
48 50.0
49}
50fn default_feed_limit() -> u32 {
51 20
52}
53
54#[derive(Serialize)]
55pub struct DiscoveryTweet {
56 pub id: String,
57 pub author_username: String,
58 pub content: String,
59 pub relevance_score: f64,
60 pub matched_keyword: Option<String>,
61 pub like_count: i64,
62 pub retweet_count: i64,
63 pub reply_count: i64,
64 pub replied_to: bool,
65 pub discovered_at: String,
66}
67
68pub async fn feed(
69 State(state): State<Arc<AppState>>,
70 ctx: AccountContext,
71 Query(q): Query<FeedQuery>,
72) -> Result<Json<Vec<DiscoveryTweet>>, ApiError> {
73 let rows = storage::tweets::get_discovery_feed_filtered_for(
74 &state.db,
75 &ctx.account_id,
76 q.min_score,
77 q.max_score,
78 q.keyword.as_deref(),
79 q.limit,
80 )
81 .await?;
82
83 let tweets = rows
84 .into_iter()
85 .map(|t| DiscoveryTweet {
86 id: t.id,
87 author_username: t.author_username,
88 content: t.content,
89 relevance_score: t.relevance_score.unwrap_or(0.0),
90 matched_keyword: t.matched_keyword,
91 like_count: t.like_count,
92 retweet_count: t.retweet_count,
93 reply_count: t.reply_count,
94 replied_to: t.replied_to != 0,
95 discovered_at: t.discovered_at,
96 })
97 .collect();
98
99 Ok(Json(tweets))
100}
101
102pub async fn keywords(
107 State(state): State<Arc<AppState>>,
108 ctx: AccountContext,
109) -> Result<Json<Vec<String>>, ApiError> {
110 let kws = storage::tweets::get_distinct_keywords_for(&state.db, &ctx.account_id).await?;
111 Ok(Json(kws))
112}
113
114#[derive(Deserialize)]
119pub struct ComposeReplyRequest {
120 #[serde(default)]
121 pub mention_product: bool,
122}
123
124#[derive(Serialize)]
125pub struct ComposeReplyResponse {
126 pub content: String,
127 pub tweet_id: String,
128}
129
130pub async fn compose_reply(
131 State(state): State<Arc<AppState>>,
132 ctx: AccountContext,
133 Path(tweet_id): Path<String>,
134 Json(body): Json<ComposeReplyRequest>,
135) -> Result<Json<ComposeReplyResponse>, ApiError> {
136 let gen = get_generator(&state, &ctx.account_id).await?;
137
138 let tweet = storage::tweets::get_tweet_by_id_for(&state.db, &ctx.account_id, &tweet_id)
140 .await?
141 .ok_or_else(|| {
142 ApiError::NotFound(format!("Tweet {tweet_id} not found in discovered tweets"))
143 })?;
144
145 let output = gen
146 .generate_reply(&tweet.content, &tweet.author_username, body.mention_product)
147 .await
148 .map_err(|e| ApiError::Internal(e.to_string()))?;
149
150 Ok(Json(ComposeReplyResponse {
151 content: output.text,
152 tweet_id,
153 }))
154}
155
156#[derive(Deserialize)]
161pub struct QueueReplyRequest {
162 pub content: String,
163}
164
165pub async fn queue_reply(
166 State(state): State<Arc<AppState>>,
167 ctx: AccountContext,
168 Path(tweet_id): Path<String>,
169 Json(body): Json<QueueReplyRequest>,
170) -> Result<Json<Value>, ApiError> {
171 require_mutate(&ctx)?;
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}