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