tuitbot_server/routes/content/
compose.rs1use 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#[derive(Deserialize)]
21pub struct ComposeTweetRequest {
22 pub text: String,
24 pub scheduled_for: Option<String>,
26}
27
28pub 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 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 "", "", text,
52 "", "", 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 Ok(Json(json!({
73 "status": "accepted",
74 "text": text,
75 "scheduled_for": body.scheduled_for,
76 })))
77 }
78}
79
80#[derive(Deserialize)]
82pub struct ComposeThreadRequest {
83 pub tweets: Vec<String>,
85 pub scheduled_for: Option<String>,
87}
88
89pub 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#[derive(Deserialize)]
143pub struct ComposeRequest {
144 pub content_type: String,
146 pub content: String,
148 pub scheduled_for: Option<String>,
150 #[serde(default)]
152 pub media_paths: Option<Vec<String>>,
153}
154
155pub 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 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}