use std::sync::Arc;
use axum::extract::State;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tuitbot_core::content::ThreadBlock;
use tuitbot_core::storage::approval_queue;
use tuitbot_core::storage::provenance::ProvenanceRef;
use crate::account::{require_mutate, AccountContext};
use crate::error::ApiError;
use crate::state::AppState;
use crate::ws::{AccountWsEvent, WsEvent};
use super::read_approval_mode;
#[derive(Debug, Deserialize)]
pub struct ThreadBlockRequest {
pub id: String,
pub text: String,
#[serde(default)]
pub media_paths: Vec<String>,
pub order: u32,
}
impl ThreadBlockRequest {
pub(crate) fn into_core(self) -> ThreadBlock {
ThreadBlock {
id: self.id,
text: self.text,
media_paths: self.media_paths,
order: self.order,
}
}
}
#[derive(Deserialize)]
pub struct ComposeTweetRequest {
pub text: String,
pub scheduled_for: Option<String>,
#[serde(default)]
pub provenance: Option<Vec<ProvenanceRef>>,
#[serde(default)]
pub hook_style: Option<String>,
}
pub async fn compose_tweet(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Json(body): Json<ComposeTweetRequest>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let text = body.text.trim();
if text.is_empty() {
return Err(ApiError::BadRequest("text is required".to_string()));
}
let approval_mode = read_approval_mode(&state, &ctx.account_id).await?;
if approval_mode {
let prov_input = build_provenance_input(body.provenance.as_deref());
let id = approval_queue::enqueue_with_provenance_for(
&state.db,
&ctx.account_id,
"tweet",
"", "", text,
"", "", 0.0,
"[]",
None,
None,
prov_input.as_ref(),
body.scheduled_for.as_deref(),
)
.await?;
let _ = state.event_tx.send(AccountWsEvent {
account_id: ctx.account_id.clone(),
event: WsEvent::ApprovalQueued {
id,
action_type: "tweet".to_string(),
content: text.to_string(),
media_paths: vec![],
},
});
Ok(Json(json!({
"status": "queued_for_approval",
"id": id,
"scheduled_for": body.scheduled_for,
})))
} else {
Ok(Json(json!({
"status": "accepted",
"text": text,
"scheduled_for": body.scheduled_for,
})))
}
}
#[derive(Deserialize)]
pub struct ComposeThreadRequest {
pub tweets: Vec<String>,
pub scheduled_for: Option<String>,
#[serde(default)]
pub provenance: Option<Vec<ProvenanceRef>>,
#[serde(default)]
pub hook_style: Option<String>,
}
pub async fn compose_thread(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Json(body): Json<ComposeThreadRequest>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
if body.tweets.is_empty() {
return Err(ApiError::BadRequest(
"tweets array must not be empty".to_string(),
));
}
let approval_mode = read_approval_mode(&state, &ctx.account_id).await?;
let combined = body.tweets.join("\n---\n");
if approval_mode {
let prov_input = build_provenance_input(body.provenance.as_deref());
let id = approval_queue::enqueue_with_provenance_for(
&state.db,
&ctx.account_id,
"thread",
"",
"",
&combined,
"",
"",
0.0,
"[]",
None,
None,
prov_input.as_ref(),
body.scheduled_for.as_deref(),
)
.await?;
let _ = state.event_tx.send(AccountWsEvent {
account_id: ctx.account_id.clone(),
event: WsEvent::ApprovalQueued {
id,
action_type: "thread".to_string(),
content: combined,
media_paths: vec![],
},
});
Ok(Json(json!({
"status": "queued_for_approval",
"id": id,
"scheduled_for": body.scheduled_for,
})))
} else {
Ok(Json(json!({
"status": "accepted",
"tweet_count": body.tweets.len(),
"scheduled_for": body.scheduled_for,
})))
}
}
#[derive(Deserialize)]
pub struct ComposeRequest {
pub content_type: String,
pub content: String,
pub scheduled_for: Option<String>,
#[serde(default)]
pub media_paths: Option<Vec<String>>,
#[serde(default)]
pub blocks: Option<Vec<ThreadBlockRequest>>,
#[serde(default)]
pub provenance: Option<Vec<ProvenanceRef>>,
#[serde(default)]
pub hook_style: Option<String>,
}
pub async fn compose(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Json(mut body): Json<ComposeRequest>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let blocks = body.blocks.take();
match body.content_type.as_str() {
"tweet" => transforms::compose_tweet_flow(&state, &ctx, &body).await,
"thread" => {
if let Some(blocks) = blocks {
transforms::compose_thread_blocks_flow(&state, &ctx, &body, blocks).await
} else {
transforms::compose_thread_legacy_flow(&state, &ctx, &body).await
}
}
_ => Err(ApiError::BadRequest(
"content_type must be 'tweet' or 'thread'".to_string(),
)),
}
}
fn build_provenance_input(
provenance: Option<&[ProvenanceRef]>,
) -> Option<approval_queue::ProvenanceInput> {
let refs = provenance?;
if refs.is_empty() {
return None;
}
let source_node_id = refs.iter().find_map(|r| r.node_id);
let source_seed_id = refs.iter().find_map(|r| r.seed_id);
let source_chunks_json = serde_json::to_string(refs).unwrap_or_else(|_| "[]".to_string());
Some(approval_queue::ProvenanceInput {
source_node_id,
source_seed_id,
source_chunks_json,
refs: refs.to_vec(),
})
}
pub(crate) mod transforms;
#[cfg(test)]
mod tests;