Skip to main content

tuitbot_server/routes/content/
compose.rs

1//! Compose endpoints for tweets, threads, and unified compose.
2
3use std::sync::Arc;
4
5use axum::extract::State;
6use axum::Json;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use tuitbot_core::content::{tweet_weighted_len, MAX_TWEET_CHARS};
10use tuitbot_core::storage::{approval_queue, scheduled_content};
11
12use crate::account::{require_mutate, AccountContext};
13use crate::error::ApiError;
14use crate::state::AppState;
15use crate::ws::WsEvent;
16
17use super::read_approval_mode;
18
19/// Request body for composing a manual tweet.
20#[derive(Deserialize)]
21pub struct ComposeTweetRequest {
22    /// The tweet text.
23    pub text: String,
24    /// Optional ISO 8601 timestamp to schedule the tweet.
25    pub scheduled_for: Option<String>,
26}
27
28/// `POST /api/content/tweets` — compose and queue a manual tweet.
29pub async fn compose_tweet(
30    State(state): State<Arc<AppState>>,
31    ctx: AccountContext,
32    Json(body): Json<ComposeTweetRequest>,
33) -> Result<Json<Value>, ApiError> {
34    require_mutate(&ctx)?;
35
36    let text = body.text.trim();
37    if text.is_empty() {
38        return Err(ApiError::BadRequest("text is required".to_string()));
39    }
40
41    // Check if approval mode is enabled.
42    let approval_mode = read_approval_mode(&state)?;
43
44    if approval_mode {
45        let id = approval_queue::enqueue_for(
46            &state.db,
47            &ctx.account_id,
48            "tweet",
49            "", // no target tweet
50            "", // no target author
51            text,
52            "", // no topic
53            "", // no archetype
54            0.0,
55            "[]",
56        )
57        .await?;
58
59        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
60            id,
61            action_type: "tweet".to_string(),
62            content: text.to_string(),
63            media_paths: vec![],
64        });
65
66        Ok(Json(json!({
67            "status": "queued_for_approval",
68            "id": id,
69        })))
70    } else {
71        // Without X API client in AppState, we can only acknowledge the intent.
72        Ok(Json(json!({
73            "status": "accepted",
74            "text": text,
75            "scheduled_for": body.scheduled_for,
76        })))
77    }
78}
79
80/// Request body for composing a manual thread.
81#[derive(Deserialize)]
82pub struct ComposeThreadRequest {
83    /// The tweets forming the thread.
84    pub tweets: Vec<String>,
85    /// Optional ISO 8601 timestamp to schedule the thread.
86    pub scheduled_for: Option<String>,
87}
88
89/// `POST /api/content/threads` — compose and queue a manual thread.
90pub async fn compose_thread(
91    State(state): State<Arc<AppState>>,
92    ctx: AccountContext,
93    Json(body): Json<ComposeThreadRequest>,
94) -> Result<Json<Value>, ApiError> {
95    require_mutate(&ctx)?;
96
97    if body.tweets.is_empty() {
98        return Err(ApiError::BadRequest(
99            "tweets array must not be empty".to_string(),
100        ));
101    }
102
103    let approval_mode = read_approval_mode(&state)?;
104    let combined = body.tweets.join("\n---\n");
105
106    if approval_mode {
107        let id = approval_queue::enqueue_for(
108            &state.db,
109            &ctx.account_id,
110            "thread",
111            "",
112            "",
113            &combined,
114            "",
115            "",
116            0.0,
117            "[]",
118        )
119        .await?;
120
121        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
122            id,
123            action_type: "thread".to_string(),
124            content: combined,
125            media_paths: vec![],
126        });
127
128        Ok(Json(json!({
129            "status": "queued_for_approval",
130            "id": id,
131        })))
132    } else {
133        Ok(Json(json!({
134            "status": "accepted",
135            "tweet_count": body.tweets.len(),
136            "scheduled_for": body.scheduled_for,
137        })))
138    }
139}
140
141/// Request body for the unified compose endpoint.
142#[derive(Deserialize)]
143pub struct ComposeRequest {
144    /// Content type: "tweet" or "thread".
145    pub content_type: String,
146    /// Content text (string for tweet, JSON array string for thread).
147    pub content: String,
148    /// Optional ISO 8601 timestamp to schedule the content.
149    pub scheduled_for: Option<String>,
150    /// Optional local media file paths to attach.
151    #[serde(default)]
152    pub media_paths: Option<Vec<String>>,
153}
154
155/// `POST /api/content/compose` — compose manual content (tweet or thread).
156pub async fn compose(
157    State(state): State<Arc<AppState>>,
158    ctx: AccountContext,
159    Json(body): Json<ComposeRequest>,
160) -> Result<Json<Value>, ApiError> {
161    require_mutate(&ctx)?;
162    let content = body.content.trim().to_string();
163    if content.is_empty() {
164        return Err(ApiError::BadRequest("content is required".to_string()));
165    }
166
167    match body.content_type.as_str() {
168        "tweet" => {
169            if tweet_weighted_len(&content) > MAX_TWEET_CHARS {
170                return Err(ApiError::BadRequest(
171                    "tweet content must not exceed 280 characters".to_string(),
172                ));
173            }
174        }
175        "thread" => {
176            // Validate that content is a JSON array of strings
177            let tweets: Result<Vec<String>, _> = serde_json::from_str(&content);
178            match tweets {
179                Ok(ref t) if t.is_empty() => {
180                    return Err(ApiError::BadRequest(
181                        "thread must contain at least one tweet".to_string(),
182                    ));
183                }
184                Ok(ref t) => {
185                    for (i, tweet) in t.iter().enumerate() {
186                        if tweet_weighted_len(tweet) > MAX_TWEET_CHARS {
187                            return Err(ApiError::BadRequest(format!(
188                                "tweet {} exceeds 280 characters",
189                                i + 1
190                            )));
191                        }
192                    }
193                }
194                Err(_) => {
195                    return Err(ApiError::BadRequest(
196                        "thread content must be a JSON array of strings".to_string(),
197                    ));
198                }
199            }
200        }
201        _ => {
202            return Err(ApiError::BadRequest(
203                "content_type must be 'tweet' or 'thread'".to_string(),
204            ));
205        }
206    }
207
208    let approval_mode = read_approval_mode(&state)?;
209
210    if approval_mode {
211        let media_paths = body.media_paths.as_deref().unwrap_or(&[]);
212        let media_json = serde_json::to_string(media_paths).unwrap_or_else(|_| "[]".to_string());
213        let id = approval_queue::enqueue_for(
214            &state.db,
215            &ctx.account_id,
216            &body.content_type,
217            "",
218            "",
219            &content,
220            "",
221            "",
222            0.0,
223            &media_json,
224        )
225        .await?;
226
227        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
228            id,
229            action_type: body.content_type,
230            content: content.clone(),
231            media_paths: media_paths.to_vec(),
232        });
233
234        Ok(Json(json!({
235            "status": "queued_for_approval",
236            "id": id,
237        })))
238    } else {
239        let id = scheduled_content::insert_for(
240            &state.db,
241            &ctx.account_id,
242            &body.content_type,
243            &content,
244            body.scheduled_for.as_deref(),
245        )
246        .await?;
247
248        let _ = state.event_tx.send(WsEvent::ContentScheduled {
249            id,
250            content_type: body.content_type,
251            scheduled_for: body.scheduled_for,
252        });
253
254        Ok(Json(json!({
255            "status": "scheduled",
256            "id": id,
257        })))
258    }
259}