Skip to main content

tuitbot_server/routes/
discovery.rs

1//! Discovery feed endpoints for browsing scored tweets and composing replies.
2
3use 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
16// ---------------------------------------------------------------------------
17// Helpers
18// ---------------------------------------------------------------------------
19
20async 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// ---------------------------------------------------------------------------
34// GET /api/discovery/feed
35// ---------------------------------------------------------------------------
36
37#[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
102// ---------------------------------------------------------------------------
103// GET /api/discovery/keywords
104// ---------------------------------------------------------------------------
105
106pub 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// ---------------------------------------------------------------------------
115// POST /api/discovery/{tweet_id}/compose-reply
116// ---------------------------------------------------------------------------
117
118#[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    // Fetch the tweet content from discovered_tweets.
139    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// ---------------------------------------------------------------------------
157// POST /api/discovery/{tweet_id}/queue-reply
158// ---------------------------------------------------------------------------
159
160#[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    // Look up author from discovered_tweets.
180    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        "",  // topic
193        "",  // archetype
194        0.0, // score
195        "[]",
196    )
197    .await?;
198
199    // Auto-approve for immediate posting.
200    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}