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 #[serde(default)]
58 pub hook_style: Option<String>,
59}
60
61pub 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 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 "", "", text,
87 "", "", 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 Ok(Json(json!({
116 "status": "accepted",
117 "text": text,
118 "scheduled_for": body.scheduled_for,
119 })))
120 }
121}
122
123#[derive(Deserialize)]
125pub struct ComposeThreadRequest {
126 pub tweets: Vec<String>,
128 pub scheduled_for: Option<String>,
130 #[serde(default)]
132 pub provenance: Option<Vec<ProvenanceRef>>,
133 #[serde(default)]
135 pub hook_style: Option<String>,
136}
137
138pub 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#[derive(Deserialize)]
202pub struct ComposeRequest {
203 pub content_type: String,
205 pub content: String,
207 pub scheduled_for: Option<String>,
209 #[serde(default)]
211 pub media_paths: Option<Vec<String>>,
212 #[serde(default)]
214 pub blocks: Option<Vec<ThreadBlockRequest>>,
215 #[serde(default)]
217 pub provenance: Option<Vec<ProvenanceRef>>,
218 #[serde(default)]
220 pub hook_style: Option<String>,
221}
222
223pub 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
248fn 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;