Skip to main content

tuitbot_server/routes/content/compose/
mod.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::ThreadBlock;
10use tuitbot_core::storage::approval_queue;
11use tuitbot_core::storage::provenance::ProvenanceRef;
12
13use crate::account::{require_mutate, AccountContext};
14use crate::error::ApiError;
15use crate::state::AppState;
16use crate::ws::{AccountWsEvent, WsEvent};
17
18use super::read_approval_mode;
19
20/// A single thread block in an API request payload.
21#[derive(Debug, Deserialize)]
22pub struct ThreadBlockRequest {
23    /// Client-generated stable UUID.
24    pub id: String,
25    /// Tweet text content.
26    pub text: String,
27    /// Per-block media file paths.
28    #[serde(default)]
29    pub media_paths: Vec<String>,
30    /// Zero-based ordering index.
31    pub order: u32,
32}
33
34impl ThreadBlockRequest {
35    /// Convert to the core domain type.
36    pub(crate) fn into_core(self) -> ThreadBlock {
37        ThreadBlock {
38            id: self.id,
39            text: self.text,
40            media_paths: self.media_paths,
41            order: self.order,
42        }
43    }
44}
45
46/// Request body for composing a manual tweet.
47#[derive(Deserialize)]
48pub struct ComposeTweetRequest {
49    /// The tweet text.
50    pub text: String,
51    /// Optional ISO 8601 timestamp to schedule the tweet.
52    pub scheduled_for: Option<String>,
53    /// Optional provenance refs linking this content to vault source material.
54    #[serde(default)]
55    pub provenance: Option<Vec<ProvenanceRef>>,
56}
57
58/// `POST /api/content/tweets` — compose and queue a manual tweet.
59pub async fn compose_tweet(
60    State(state): State<Arc<AppState>>,
61    ctx: AccountContext,
62    Json(body): Json<ComposeTweetRequest>,
63) -> Result<Json<Value>, ApiError> {
64    require_mutate(&ctx)?;
65
66    let text = body.text.trim();
67    if text.is_empty() {
68        return Err(ApiError::BadRequest("text is required".to_string()));
69    }
70
71    // Check if approval mode is enabled.
72    let approval_mode = read_approval_mode(&state, &ctx.account_id).await?;
73
74    if approval_mode {
75        let prov_input = build_provenance_input(body.provenance.as_deref());
76
77        let id = approval_queue::enqueue_with_provenance_for(
78            &state.db,
79            &ctx.account_id,
80            "tweet",
81            "", // no target tweet
82            "", // no target author
83            text,
84            "", // no topic
85            "", // no archetype
86            0.0,
87            "[]",
88            None,
89            None,
90            prov_input.as_ref(),
91            body.scheduled_for.as_deref(),
92        )
93        .await?;
94
95        let _ = state.event_tx.send(AccountWsEvent {
96            account_id: ctx.account_id.clone(),
97            event: WsEvent::ApprovalQueued {
98                id,
99                action_type: "tweet".to_string(),
100                content: text.to_string(),
101                media_paths: vec![],
102            },
103        });
104
105        Ok(Json(json!({
106            "status": "queued_for_approval",
107            "id": id,
108            "scheduled_for": body.scheduled_for,
109        })))
110    } else {
111        // Without X API client in AppState, we can only acknowledge the intent.
112        Ok(Json(json!({
113            "status": "accepted",
114            "text": text,
115            "scheduled_for": body.scheduled_for,
116        })))
117    }
118}
119
120/// Request body for composing a manual thread.
121#[derive(Deserialize)]
122pub struct ComposeThreadRequest {
123    /// The tweets forming the thread.
124    pub tweets: Vec<String>,
125    /// Optional ISO 8601 timestamp to schedule the thread.
126    pub scheduled_for: Option<String>,
127}
128
129/// `POST /api/content/threads` — compose and queue a manual thread.
130pub async fn compose_thread(
131    State(state): State<Arc<AppState>>,
132    ctx: AccountContext,
133    Json(body): Json<ComposeThreadRequest>,
134) -> Result<Json<Value>, ApiError> {
135    require_mutate(&ctx)?;
136
137    if body.tweets.is_empty() {
138        return Err(ApiError::BadRequest(
139            "tweets array must not be empty".to_string(),
140        ));
141    }
142
143    let approval_mode = read_approval_mode(&state, &ctx.account_id).await?;
144    let combined = body.tweets.join("\n---\n");
145
146    if approval_mode {
147        let id = approval_queue::enqueue_with_context_for(
148            &state.db,
149            &ctx.account_id,
150            "thread",
151            "",
152            "",
153            &combined,
154            "",
155            "",
156            0.0,
157            "[]",
158            None,
159            None,
160            body.scheduled_for.as_deref(),
161        )
162        .await?;
163
164        let _ = state.event_tx.send(AccountWsEvent {
165            account_id: ctx.account_id.clone(),
166            event: WsEvent::ApprovalQueued {
167                id,
168                action_type: "thread".to_string(),
169                content: combined,
170                media_paths: vec![],
171            },
172        });
173
174        Ok(Json(json!({
175            "status": "queued_for_approval",
176            "id": id,
177            "scheduled_for": body.scheduled_for,
178        })))
179    } else {
180        Ok(Json(json!({
181            "status": "accepted",
182            "tweet_count": body.tweets.len(),
183            "scheduled_for": body.scheduled_for,
184        })))
185    }
186}
187
188/// Request body for the unified compose endpoint.
189#[derive(Deserialize)]
190pub struct ComposeRequest {
191    /// Content type: "tweet" or "thread".
192    pub content_type: String,
193    /// Content text (string for tweet, JSON array string for thread).
194    pub content: String,
195    /// Optional ISO 8601 timestamp to schedule the content.
196    pub scheduled_for: Option<String>,
197    /// Optional local media file paths to attach (top-level, used for tweets).
198    #[serde(default)]
199    pub media_paths: Option<Vec<String>>,
200    /// Optional structured thread blocks. Takes precedence over `content` for threads.
201    #[serde(default)]
202    pub blocks: Option<Vec<ThreadBlockRequest>>,
203    /// Optional provenance refs linking this content to vault source material.
204    #[serde(default)]
205    pub provenance: Option<Vec<ProvenanceRef>>,
206}
207
208/// `POST /api/content/compose` — compose manual content (tweet or thread).
209pub async fn compose(
210    State(state): State<Arc<AppState>>,
211    ctx: AccountContext,
212    Json(mut body): Json<ComposeRequest>,
213) -> Result<Json<Value>, ApiError> {
214    require_mutate(&ctx)?;
215
216    let blocks = body.blocks.take();
217
218    match body.content_type.as_str() {
219        "tweet" => transforms::compose_tweet_flow(&state, &ctx, &body).await,
220        "thread" => {
221            if let Some(blocks) = blocks {
222                transforms::compose_thread_blocks_flow(&state, &ctx, &body, blocks).await
223            } else {
224                transforms::compose_thread_legacy_flow(&state, &ctx, &body).await
225            }
226        }
227        _ => Err(ApiError::BadRequest(
228            "content_type must be 'tweet' or 'thread'".to_string(),
229        )),
230    }
231}
232
233// Handle tweet compose via the unified endpoint.
234
235// ---------------------------------------------------------------------------
236// Helpers used by the handlers above (kept here to avoid cross-module imports)
237// ---------------------------------------------------------------------------
238
239fn build_provenance_input(
240    provenance: Option<&[ProvenanceRef]>,
241) -> Option<approval_queue::ProvenanceInput> {
242    let refs = provenance?;
243    if refs.is_empty() {
244        return None;
245    }
246
247    let source_node_id = refs.iter().find_map(|r| r.node_id);
248    let source_seed_id = refs.iter().find_map(|r| r.seed_id);
249    let source_chunks_json = serde_json::to_string(refs).unwrap_or_else(|_| "[]".to_string());
250
251    Some(approval_queue::ProvenanceInput {
252        source_node_id,
253        source_seed_id,
254        source_chunks_json,
255        refs: refs.to_vec(),
256    })
257}
258
259pub(crate) mod transforms;
260
261#[cfg(test)]
262mod tests;