1use 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#[derive(Debug, Deserialize)]
24pub struct ThreadBlockRequest {
25 pub id: String,
27 pub text: String,
29 #[serde(default)]
31 pub media_paths: Vec<String>,
32 pub order: u32,
34}
35
36impl ThreadBlockRequest {
37 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#[derive(Deserialize)]
50pub struct ComposeTweetRequest {
51 pub text: String,
53 pub scheduled_for: Option<String>,
55}
56
57pub 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 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 "", "", text,
81 "", "", 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 Ok(Json(json!({
102 "status": "accepted",
103 "text": text,
104 "scheduled_for": body.scheduled_for,
105 })))
106 }
107}
108
109#[derive(Deserialize)]
111pub struct ComposeThreadRequest {
112 pub tweets: Vec<String>,
114 pub scheduled_for: Option<String>,
116}
117
118pub 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#[derive(Deserialize)]
172pub struct ComposeRequest {
173 pub content_type: String,
175 pub content: String,
177 pub scheduled_for: Option<String>,
179 #[serde(default)]
181 pub media_paths: Option<Vec<String>>,
182 #[serde(default)]
184 pub blocks: Option<Vec<ThreadBlockRequest>>,
185}
186
187pub 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
212async 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
231async 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
264async 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 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
344async 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}