tuitbot_server/routes/content/compose/
mod.rs1use 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#[derive(Debug, Deserialize)]
22pub struct ThreadBlockRequest {
23 pub id: String,
25 pub text: String,
27 #[serde(default)]
29 pub media_paths: Vec<String>,
30 pub order: u32,
32}
33
34impl ThreadBlockRequest {
35 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#[derive(Deserialize)]
48pub struct ComposeTweetRequest {
49 pub text: String,
51 pub scheduled_for: Option<String>,
53 #[serde(default)]
55 pub provenance: Option<Vec<ProvenanceRef>>,
56}
57
58pub 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 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 "", "", text,
84 "", "", 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 Ok(Json(json!({
113 "status": "accepted",
114 "text": text,
115 "scheduled_for": body.scheduled_for,
116 })))
117 }
118}
119
120#[derive(Deserialize)]
122pub struct ComposeThreadRequest {
123 pub tweets: Vec<String>,
125 pub scheduled_for: Option<String>,
127}
128
129pub 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#[derive(Deserialize)]
190pub struct ComposeRequest {
191 pub content_type: String,
193 pub content: String,
195 pub scheduled_for: Option<String>,
197 #[serde(default)]
199 pub media_paths: Option<Vec<String>>,
200 #[serde(default)]
202 pub blocks: Option<Vec<ThreadBlockRequest>>,
203 #[serde(default)]
205 pub provenance: Option<Vec<ProvenanceRef>>,
206}
207
208pub 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
233fn 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;