Skip to main content

tuitbot_server/routes/
content.rs

1//! Content endpoints (tweets and threads).
2
3use std::sync::Arc;
4
5use axum::extract::{Query, State};
6use axum::Json;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use tuitbot_core::config::Config;
10use tuitbot_core::storage::{approval_queue, threads};
11
12use crate::error::ApiError;
13use crate::state::AppState;
14use crate::ws::WsEvent;
15
16/// Query parameters for the tweets endpoint.
17#[derive(Deserialize)]
18pub struct TweetsQuery {
19    /// Maximum number of tweets to return (default: 50).
20    #[serde(default = "default_tweet_limit")]
21    pub limit: u32,
22}
23
24fn default_tweet_limit() -> u32 {
25    50
26}
27
28/// Query parameters for the threads endpoint.
29#[derive(Deserialize)]
30pub struct ThreadsQuery {
31    /// Maximum number of threads to return (default: 20).
32    #[serde(default = "default_thread_limit")]
33    pub limit: u32,
34}
35
36fn default_thread_limit() -> u32 {
37    20
38}
39
40/// `GET /api/content/tweets` — recent original tweets posted.
41pub async fn list_tweets(
42    State(state): State<Arc<AppState>>,
43    Query(params): Query<TweetsQuery>,
44) -> Result<Json<Value>, ApiError> {
45    let tweets = threads::get_recent_original_tweets(&state.db, params.limit).await?;
46    Ok(Json(json!(tweets)))
47}
48
49/// `GET /api/content/threads` — recent threads posted.
50pub async fn list_threads(
51    State(state): State<Arc<AppState>>,
52    Query(params): Query<ThreadsQuery>,
53) -> Result<Json<Value>, ApiError> {
54    let threads = threads::get_recent_threads(&state.db, params.limit).await?;
55    Ok(Json(json!(threads)))
56}
57
58/// Request body for composing a manual tweet.
59#[derive(Deserialize)]
60pub struct ComposeTweetRequest {
61    /// The tweet text.
62    pub text: String,
63    /// Optional ISO 8601 timestamp to schedule the tweet.
64    pub scheduled_for: Option<String>,
65}
66
67/// `POST /api/content/tweets` — compose and queue a manual tweet.
68pub async fn compose_tweet(
69    State(state): State<Arc<AppState>>,
70    Json(body): Json<ComposeTweetRequest>,
71) -> Result<Json<Value>, ApiError> {
72    let text = body.text.trim();
73    if text.is_empty() {
74        return Err(ApiError::BadRequest("text is required".to_string()));
75    }
76
77    // Check if approval mode is enabled.
78    let approval_mode = read_approval_mode(&state)?;
79
80    if approval_mode {
81        let id = approval_queue::enqueue(
82            &state.db, "tweet", "", // no target tweet
83            "", // no target author
84            text, "", // no topic
85            "", // no archetype
86            0.0,
87        )
88        .await?;
89
90        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
91            id,
92            action_type: "tweet".to_string(),
93            content: text.to_string(),
94        });
95
96        Ok(Json(json!({
97            "status": "queued_for_approval",
98            "id": id,
99        })))
100    } else {
101        // Without X API client in AppState, we can only acknowledge the intent.
102        Ok(Json(json!({
103            "status": "accepted",
104            "text": text,
105            "scheduled_for": body.scheduled_for,
106        })))
107    }
108}
109
110/// Request body for composing a manual thread.
111#[derive(Deserialize)]
112pub struct ComposeThreadRequest {
113    /// The tweets forming the thread.
114    pub tweets: Vec<String>,
115    /// Optional ISO 8601 timestamp to schedule the thread.
116    pub scheduled_for: Option<String>,
117}
118
119/// `POST /api/content/threads` — compose and queue a manual thread.
120pub async fn compose_thread(
121    State(state): State<Arc<AppState>>,
122    Json(body): Json<ComposeThreadRequest>,
123) -> Result<Json<Value>, ApiError> {
124    if body.tweets.is_empty() {
125        return Err(ApiError::BadRequest(
126            "tweets array must not be empty".to_string(),
127        ));
128    }
129
130    let approval_mode = read_approval_mode(&state)?;
131    let combined = body.tweets.join("\n---\n");
132
133    if approval_mode {
134        let id =
135            approval_queue::enqueue(&state.db, "thread", "", "", &combined, "", "", 0.0).await?;
136
137        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
138            id,
139            action_type: "thread".to_string(),
140            content: combined,
141        });
142
143        Ok(Json(json!({
144            "status": "queued_for_approval",
145            "id": id,
146        })))
147    } else {
148        Ok(Json(json!({
149            "status": "accepted",
150            "tweet_count": body.tweets.len(),
151            "scheduled_for": body.scheduled_for,
152        })))
153    }
154}
155
156/// Read `approval_mode` from the config file.
157fn read_approval_mode(state: &AppState) -> Result<bool, ApiError> {
158    let contents = std::fs::read_to_string(&state.config_path).unwrap_or_default();
159    let config: Config = toml::from_str(&contents).unwrap_or_default();
160    Ok(config.approval_mode)
161}