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::storage::{self, approval_queue};
10
11use crate::error::ApiError;
12use crate::state::AppState;
13
14// ---------------------------------------------------------------------------
15// GET /api/discovery/feed
16// ---------------------------------------------------------------------------
17
18#[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// ---------------------------------------------------------------------------
73// POST /api/discovery/{tweet_id}/compose-reply
74// ---------------------------------------------------------------------------
75
76#[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    // Fetch the tweet content from discovered_tweets.
101    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// ---------------------------------------------------------------------------
119// POST /api/discovery/{tweet_id}/queue-reply
120// ---------------------------------------------------------------------------
121
122#[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    // Look up author from discovered_tweets.
139    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        "",  // topic
151        "",  // archetype
152        0.0, // score
153        "[]",
154    )
155    .await?;
156
157    // Auto-approve for immediate posting.
158    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}