use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tuitbot_core::content::ContentGenerator;
use tuitbot_core::context::retrieval::VaultCitation;
use tuitbot_core::storage::approval_queue::{self, ProvenanceInput};
use tuitbot_core::storage::provenance::ProvenanceRef;
use tuitbot_core::storage::{self};
use crate::account::{require_mutate, AccountContext};
use crate::error::ApiError;
use crate::routes::rag_helpers::resolve_composer_rag_context;
use crate::state::AppState;
async fn get_generator(
state: &AppState,
account_id: &str,
) -> Result<Arc<ContentGenerator>, ApiError> {
state
.get_or_create_content_generator(account_id)
.await
.map_err(ApiError::BadRequest)
}
#[derive(Deserialize)]
pub struct FeedQuery {
#[serde(default = "default_min_score")]
pub min_score: f64,
pub max_score: Option<f64>,
pub keyword: Option<String>,
#[serde(default = "default_feed_limit")]
pub limit: u32,
}
fn default_min_score() -> f64 {
50.0
}
fn default_feed_limit() -> u32 {
20
}
#[derive(Serialize)]
pub struct DiscoveryTweet {
pub id: String,
pub author_username: String,
pub content: String,
pub relevance_score: f64,
pub matched_keyword: Option<String>,
pub like_count: i64,
pub retweet_count: i64,
pub reply_count: i64,
pub replied_to: bool,
pub discovered_at: String,
}
pub async fn feed(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Query(q): Query<FeedQuery>,
) -> Result<Json<Vec<DiscoveryTweet>>, ApiError> {
let rows = storage::tweets::get_discovery_feed_filtered_for(
&state.db,
&ctx.account_id,
q.min_score,
q.max_score,
q.keyword.as_deref(),
q.limit,
)
.await?;
let tweets = rows
.into_iter()
.map(|t| DiscoveryTweet {
id: t.id,
author_username: t.author_username,
content: t.content,
relevance_score: t.relevance_score.unwrap_or(0.0),
matched_keyword: t.matched_keyword,
like_count: t.like_count,
retweet_count: t.retweet_count,
reply_count: t.reply_count,
replied_to: t.replied_to != 0,
discovered_at: t.discovered_at,
})
.collect();
Ok(Json(tweets))
}
pub async fn keywords(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
) -> Result<Json<Vec<String>>, ApiError> {
let kws = storage::tweets::get_distinct_keywords_for(&state.db, &ctx.account_id).await?;
Ok(Json(kws))
}
#[derive(Deserialize)]
pub struct ComposeReplyRequest {
#[serde(default)]
pub mention_product: bool,
#[serde(default)]
pub selected_node_ids: Option<Vec<i64>>,
}
#[derive(Serialize)]
pub struct ComposeReplyResponse {
pub content: String,
pub tweet_id: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub vault_citations: Vec<VaultCitation>,
}
pub async fn compose_reply(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(tweet_id): Path<String>,
Json(body): Json<ComposeReplyRequest>,
) -> Result<Json<ComposeReplyResponse>, ApiError> {
let gen = get_generator(&state, &ctx.account_id).await?;
let tweet = storage::tweets::get_tweet_by_id_for(&state.db, &ctx.account_id, &tweet_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!("Tweet {tweet_id} not found in discovered tweets"))
})?;
let node_ids = body.selected_node_ids.as_deref();
let rag_context = resolve_composer_rag_context(&state, &ctx.account_id, node_ids).await;
let prompt_block = rag_context.as_ref().map(|c| c.prompt_block.as_str());
let citations = rag_context
.as_ref()
.map(|c| c.vault_citations.clone())
.unwrap_or_default();
let output = gen
.generate_reply_with_context(
&tweet.content,
&tweet.author_username,
body.mention_product,
None,
prompt_block,
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(ComposeReplyResponse {
content: output.text,
tweet_id,
vault_citations: citations,
}))
}
#[derive(Deserialize)]
pub struct QueueReplyRequest {
pub content: String,
#[serde(default)]
pub provenance: Option<Vec<ProvenanceRef>>,
}
pub async fn queue_reply(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(tweet_id): Path<String>,
Json(body): Json<QueueReplyRequest>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
crate::routes::content::require_post_capable(&state, &ctx.account_id).await?;
if body.content.trim().is_empty() {
return Err(ApiError::BadRequest(
"content must not be empty".to_string(),
));
}
let target_author = storage::tweets::get_tweet_by_id_for(&state.db, &ctx.account_id, &tweet_id)
.await?
.map(|t| t.author_username)
.unwrap_or_default();
let provenance_input = body.provenance.as_ref().map(|refs| ProvenanceInput {
source_node_id: refs.first().and_then(|r| r.node_id),
source_seed_id: None,
source_chunks_json: "[]".to_string(),
refs: refs.clone(),
});
let queue_id = approval_queue::enqueue_with_provenance_for(
&state.db,
&ctx.account_id,
"reply",
&tweet_id,
&target_author,
&body.content,
"", "", 0.0, "[]",
None, None, provenance_input.as_ref(),
None, )
.await?;
approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved").await?;
Ok(Json(json!({
"approval_queue_id": queue_id,
"tweet_id": tweet_id,
"status": "queued_for_posting"
})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn queue_reply_request_provenance_is_optional() {
let json = r#"{"content": "Great reply!"}"#;
let req: QueueReplyRequest = serde_json::from_str(json).expect("deserialize");
assert_eq!(req.content, "Great reply!");
assert!(req.provenance.is_none());
}
#[test]
fn queue_reply_request_with_provenance() {
let json = r#"{
"content": "Thanks!",
"provenance": [{"node_id": 1, "chunk_id": 2, "source_path": "notes/foo.md"}]
}"#;
let req: QueueReplyRequest = serde_json::from_str(json).expect("deserialize");
let refs = req.provenance.unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].node_id, Some(1));
}
#[test]
fn compose_reply_request_selected_node_ids_optional() {
let json = r#"{"mention_product": true}"#;
let req: ComposeReplyRequest = serde_json::from_str(json).expect("deserialize");
assert!(req.mention_product);
assert!(req.selected_node_ids.is_none());
}
#[test]
fn feed_query_defaults() {
let json = "{}";
let q: FeedQuery = serde_json::from_str(json).expect("deser");
assert!((q.min_score - 50.0).abs() < 0.001);
assert!(q.max_score.is_none());
assert!(q.keyword.is_none());
assert_eq!(q.limit, 20);
}
#[test]
fn feed_query_custom() {
let json = r#"{"min_score":70.0,"max_score":95.0,"keyword":"rust","limit":10}"#;
let q: FeedQuery = serde_json::from_str(json).expect("deser");
assert!((q.min_score - 70.0).abs() < 0.001);
assert!((q.max_score.unwrap() - 95.0).abs() < 0.001);
assert_eq!(q.keyword.as_deref(), Some("rust"));
assert_eq!(q.limit, 10);
}
#[test]
fn discovery_tweet_serializes() {
let tweet = DiscoveryTweet {
id: "t123".into(),
author_username: "alice".into(),
content: "Hello world".into(),
relevance_score: 85.5,
matched_keyword: Some("rust".into()),
like_count: 10,
retweet_count: 3,
reply_count: 1,
replied_to: false,
discovered_at: "2026-03-15T10:00:00Z".into(),
};
let json = serde_json::to_string(&tweet).expect("serialize");
assert!(json.contains("alice"));
assert!(json.contains("85.5"));
assert!(json.contains("\"replied_to\":false"));
}
#[test]
fn compose_reply_request_defaults() {
let json = "{}";
let req: ComposeReplyRequest = serde_json::from_str(json).expect("deser");
assert!(!req.mention_product);
assert!(req.selected_node_ids.is_none());
}
#[test]
fn compose_reply_request_with_node_ids() {
let json = r#"{"mention_product":true,"selected_node_ids":[1,2,3]}"#;
let req: ComposeReplyRequest = serde_json::from_str(json).expect("deser");
assert!(req.mention_product);
let ids = req.selected_node_ids.unwrap();
assert_eq!(ids.len(), 3);
}
#[test]
fn compose_reply_response_omits_empty_citations() {
let resp = ComposeReplyResponse {
content: "Nice!".to_string(),
tweet_id: "123".to_string(),
vault_citations: vec![],
};
let json = serde_json::to_string(&resp).expect("serialize");
assert!(!json.contains("vault_citations"));
}
}