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::{
10    serialize_blocks_for_storage, tweet_weighted_len, validate_thread_blocks, ThreadBlock,
11    MAX_TWEET_CHARS,
12};
13use tuitbot_core::storage::{approval_queue, scheduled_content};
14
15use crate::account::{require_mutate, AccountContext};
16use crate::error::ApiError;
17use crate::state::AppState;
18use crate::ws::WsEvent;
19
20use super::read_approval_mode;
21
22/// A single thread block in an API request payload.
23#[derive(Debug, Deserialize)]
24pub struct ThreadBlockRequest {
25    /// Client-generated stable UUID.
26    pub id: String,
27    /// Tweet text content.
28    pub text: String,
29    /// Per-block media file paths.
30    #[serde(default)]
31    pub media_paths: Vec<String>,
32    /// Zero-based ordering index.
33    pub order: u32,
34}
35
36impl ThreadBlockRequest {
37    /// Convert to the core domain type.
38    pub(crate) fn into_core(self) -> ThreadBlock {
39        ThreadBlock {
40            id: self.id,
41            text: self.text,
42            media_paths: self.media_paths,
43            order: self.order,
44        }
45    }
46}
47
48/// Request body for composing a manual tweet.
49#[derive(Deserialize)]
50pub struct ComposeTweetRequest {
51    /// The tweet text.
52    pub text: String,
53    /// Optional ISO 8601 timestamp to schedule the tweet.
54    pub scheduled_for: Option<String>,
55}
56
57/// `POST /api/content/tweets` — compose and queue a manual tweet.
58pub async fn compose_tweet(
59    State(state): State<Arc<AppState>>,
60    ctx: AccountContext,
61    Json(body): Json<ComposeTweetRequest>,
62) -> Result<Json<Value>, ApiError> {
63    require_mutate(&ctx)?;
64
65    let text = body.text.trim();
66    if text.is_empty() {
67        return Err(ApiError::BadRequest("text is required".to_string()));
68    }
69
70    // Check if approval mode is enabled.
71    let approval_mode = read_approval_mode(&state)?;
72
73    if approval_mode {
74        let id = approval_queue::enqueue_for(
75            &state.db,
76            &ctx.account_id,
77            "tweet",
78            "", // no target tweet
79            "", // no target author
80            text,
81            "", // no topic
82            "", // no archetype
83            0.0,
84            "[]",
85        )
86        .await?;
87
88        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
89            id,
90            action_type: "tweet".to_string(),
91            content: text.to_string(),
92            media_paths: vec![],
93        });
94
95        Ok(Json(json!({
96            "status": "queued_for_approval",
97            "id": id,
98        })))
99    } else {
100        // Without X API client in AppState, we can only acknowledge the intent.
101        Ok(Json(json!({
102            "status": "accepted",
103            "text": text,
104            "scheduled_for": body.scheduled_for,
105        })))
106    }
107}
108
109/// Request body for composing a manual thread.
110#[derive(Deserialize)]
111pub struct ComposeThreadRequest {
112    /// The tweets forming the thread.
113    pub tweets: Vec<String>,
114    /// Optional ISO 8601 timestamp to schedule the thread.
115    pub scheduled_for: Option<String>,
116}
117
118/// `POST /api/content/threads` — compose and queue a manual thread.
119pub async fn compose_thread(
120    State(state): State<Arc<AppState>>,
121    ctx: AccountContext,
122    Json(body): Json<ComposeThreadRequest>,
123) -> Result<Json<Value>, ApiError> {
124    require_mutate(&ctx)?;
125
126    if body.tweets.is_empty() {
127        return Err(ApiError::BadRequest(
128            "tweets array must not be empty".to_string(),
129        ));
130    }
131
132    let approval_mode = read_approval_mode(&state)?;
133    let combined = body.tweets.join("\n---\n");
134
135    if approval_mode {
136        let id = approval_queue::enqueue_for(
137            &state.db,
138            &ctx.account_id,
139            "thread",
140            "",
141            "",
142            &combined,
143            "",
144            "",
145            0.0,
146            "[]",
147        )
148        .await?;
149
150        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
151            id,
152            action_type: "thread".to_string(),
153            content: combined,
154            media_paths: vec![],
155        });
156
157        Ok(Json(json!({
158            "status": "queued_for_approval",
159            "id": id,
160        })))
161    } else {
162        Ok(Json(json!({
163            "status": "accepted",
164            "tweet_count": body.tweets.len(),
165            "scheduled_for": body.scheduled_for,
166        })))
167    }
168}
169
170/// Request body for the unified compose endpoint.
171#[derive(Deserialize)]
172pub struct ComposeRequest {
173    /// Content type: "tweet" or "thread".
174    pub content_type: String,
175    /// Content text (string for tweet, JSON array string for thread).
176    pub content: String,
177    /// Optional ISO 8601 timestamp to schedule the content.
178    pub scheduled_for: Option<String>,
179    /// Optional local media file paths to attach (top-level, used for tweets).
180    #[serde(default)]
181    pub media_paths: Option<Vec<String>>,
182    /// Optional structured thread blocks. Takes precedence over `content` for threads.
183    #[serde(default)]
184    pub blocks: Option<Vec<ThreadBlockRequest>>,
185}
186
187/// `POST /api/content/compose` — compose manual content (tweet or thread).
188pub async fn compose(
189    State(state): State<Arc<AppState>>,
190    ctx: AccountContext,
191    Json(mut body): Json<ComposeRequest>,
192) -> Result<Json<Value>, ApiError> {
193    require_mutate(&ctx)?;
194
195    let blocks = body.blocks.take();
196
197    match body.content_type.as_str() {
198        "tweet" => compose_tweet_flow(&state, &ctx, &body).await,
199        "thread" => {
200            if let Some(blocks) = blocks {
201                compose_thread_blocks_flow(&state, &ctx, &body, blocks).await
202            } else {
203                compose_thread_legacy_flow(&state, &ctx, &body).await
204            }
205        }
206        _ => Err(ApiError::BadRequest(
207            "content_type must be 'tweet' or 'thread'".to_string(),
208        )),
209    }
210}
211
212/// Handle tweet compose via the unified endpoint.
213async fn compose_tweet_flow(
214    state: &AppState,
215    ctx: &AccountContext,
216    body: &ComposeRequest,
217) -> Result<Json<Value>, ApiError> {
218    let content = body.content.trim().to_string();
219    if content.is_empty() {
220        return Err(ApiError::BadRequest("content is required".to_string()));
221    }
222    if tweet_weighted_len(&content) > MAX_TWEET_CHARS {
223        return Err(ApiError::BadRequest(
224            "tweet content must not exceed 280 characters".to_string(),
225        ));
226    }
227
228    persist_content(state, ctx, body, &content).await
229}
230
231/// Handle legacy thread compose (content as JSON array of strings).
232async fn compose_thread_legacy_flow(
233    state: &AppState,
234    ctx: &AccountContext,
235    body: &ComposeRequest,
236) -> Result<Json<Value>, ApiError> {
237    let content = body.content.trim().to_string();
238    if content.is_empty() {
239        return Err(ApiError::BadRequest("content is required".to_string()));
240    }
241
242    let tweets: Vec<String> = serde_json::from_str(&content).map_err(|_| {
243        ApiError::BadRequest("thread content must be a JSON array of strings".to_string())
244    })?;
245
246    if tweets.is_empty() {
247        return Err(ApiError::BadRequest(
248            "thread must contain at least one tweet".to_string(),
249        ));
250    }
251
252    for (i, tweet) in tweets.iter().enumerate() {
253        if tweet_weighted_len(tweet) > MAX_TWEET_CHARS {
254            return Err(ApiError::BadRequest(format!(
255                "tweet {} exceeds 280 characters",
256                i + 1
257            )));
258        }
259    }
260
261    persist_content(state, ctx, body, &content).await
262}
263
264/// Handle structured thread blocks compose.
265async fn compose_thread_blocks_flow(
266    state: &AppState,
267    ctx: &AccountContext,
268    body: &ComposeRequest,
269    block_requests: Vec<ThreadBlockRequest>,
270) -> Result<Json<Value>, ApiError> {
271    let core_blocks: Vec<ThreadBlock> = block_requests.into_iter().map(|b| b.into_core()).collect();
272
273    validate_thread_blocks(&core_blocks).map_err(|e| ApiError::BadRequest(e.api_message()))?;
274
275    let block_ids: Vec<String> = {
276        let mut sorted = core_blocks.clone();
277        sorted.sort_by_key(|b| b.order);
278        sorted.iter().map(|b| b.id.clone()).collect()
279    };
280
281    let content = serialize_blocks_for_storage(&core_blocks);
282
283    // Collect per-block media into a flat list for approval queue storage.
284    let all_media: Vec<String> = {
285        let mut sorted = core_blocks.clone();
286        sorted.sort_by_key(|b| b.order);
287        sorted.iter().flat_map(|b| b.media_paths.clone()).collect()
288    };
289
290    let approval_mode = read_approval_mode(state)?;
291
292    if approval_mode {
293        let media_json = serde_json::to_string(&all_media).unwrap_or_else(|_| "[]".to_string());
294        let id = approval_queue::enqueue_for(
295            &state.db,
296            &ctx.account_id,
297            "thread",
298            "",
299            "",
300            &content,
301            "",
302            "",
303            0.0,
304            &media_json,
305        )
306        .await?;
307
308        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
309            id,
310            action_type: "thread".to_string(),
311            content: content.clone(),
312            media_paths: all_media,
313        });
314
315        Ok(Json(json!({
316            "status": "queued_for_approval",
317            "id": id,
318            "block_ids": block_ids,
319        })))
320    } else {
321        let id = scheduled_content::insert_for(
322            &state.db,
323            &ctx.account_id,
324            "thread",
325            &content,
326            body.scheduled_for.as_deref(),
327        )
328        .await?;
329
330        let _ = state.event_tx.send(WsEvent::ContentScheduled {
331            id,
332            content_type: "thread".to_string(),
333            scheduled_for: body.scheduled_for.clone(),
334        });
335
336        Ok(Json(json!({
337            "status": "scheduled",
338            "id": id,
339            "block_ids": block_ids,
340        })))
341    }
342}
343
344/// Persist content via approval queue or scheduled content table.
345async fn persist_content(
346    state: &AppState,
347    ctx: &AccountContext,
348    body: &ComposeRequest,
349    content: &str,
350) -> Result<Json<Value>, ApiError> {
351    let approval_mode = read_approval_mode(state)?;
352
353    if approval_mode {
354        let media_paths = body.media_paths.as_deref().unwrap_or(&[]);
355        let media_json = serde_json::to_string(media_paths).unwrap_or_else(|_| "[]".to_string());
356        let id = approval_queue::enqueue_for(
357            &state.db,
358            &ctx.account_id,
359            &body.content_type,
360            "",
361            "",
362            content,
363            "",
364            "",
365            0.0,
366            &media_json,
367        )
368        .await?;
369
370        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
371            id,
372            action_type: body.content_type.clone(),
373            content: content.to_string(),
374            media_paths: media_paths.to_vec(),
375        });
376
377        Ok(Json(json!({
378            "status": "queued_for_approval",
379            "id": id,
380        })))
381    } else {
382        let id = scheduled_content::insert_for(
383            &state.db,
384            &ctx.account_id,
385            &body.content_type,
386            content,
387            body.scheduled_for.as_deref(),
388        )
389        .await?;
390
391        let _ = state.event_tx.send(WsEvent::ContentScheduled {
392            id,
393            content_type: body.content_type.clone(),
394            scheduled_for: body.scheduled_for.clone(),
395        });
396
397        Ok(Json(json!({
398            "status": "scheduled",
399            "id": id,
400        })))
401    }
402}