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    state
25        .get_or_create_content_generator(account_id)
26        .await
27        .map_err(ApiError::BadRequest)
28}
29
30// ---------------------------------------------------------------------------
31// GET /api/discovery/feed
32// ---------------------------------------------------------------------------
33
34#[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
99// ---------------------------------------------------------------------------
100// GET /api/discovery/keywords
101// ---------------------------------------------------------------------------
102
103pub 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// ---------------------------------------------------------------------------
112// POST /api/discovery/{tweet_id}/compose-reply
113// ---------------------------------------------------------------------------
114
115#[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    // Fetch the tweet content from discovered_tweets.
136    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// ---------------------------------------------------------------------------
154// POST /api/discovery/{tweet_id}/queue-reply
155// ---------------------------------------------------------------------------
156
157#[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    // Block posting unless the backend can actually post for this account.
171    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    // 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}