1use std::sync::Arc;
4
5use axum::extract::{Path, Query, State};
6use axum::Json;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use tuitbot_core::config::Config;
10use tuitbot_core::storage::{approval_queue, replies, scheduled_content, threads};
11
12use crate::error::ApiError;
13use crate::state::AppState;
14use crate::ws::WsEvent;
15
16#[derive(Deserialize)]
22pub struct TweetsQuery {
23 #[serde(default = "default_tweet_limit")]
25 pub limit: u32,
26}
27
28fn default_tweet_limit() -> u32 {
29 50
30}
31
32#[derive(Deserialize)]
34pub struct ThreadsQuery {
35 #[serde(default = "default_thread_limit")]
37 pub limit: u32,
38}
39
40fn default_thread_limit() -> u32 {
41 20
42}
43
44pub async fn list_tweets(
46 State(state): State<Arc<AppState>>,
47 Query(params): Query<TweetsQuery>,
48) -> Result<Json<Value>, ApiError> {
49 let tweets = threads::get_recent_original_tweets(&state.db, params.limit).await?;
50 Ok(Json(json!(tweets)))
51}
52
53pub async fn list_threads(
55 State(state): State<Arc<AppState>>,
56 Query(params): Query<ThreadsQuery>,
57) -> Result<Json<Value>, ApiError> {
58 let threads = threads::get_recent_threads(&state.db, params.limit).await?;
59 Ok(Json(json!(threads)))
60}
61
62#[derive(Deserialize)]
64pub struct ComposeTweetRequest {
65 pub text: String,
67 pub scheduled_for: Option<String>,
69}
70
71pub async fn compose_tweet(
73 State(state): State<Arc<AppState>>,
74 Json(body): Json<ComposeTweetRequest>,
75) -> Result<Json<Value>, ApiError> {
76 let text = body.text.trim();
77 if text.is_empty() {
78 return Err(ApiError::BadRequest("text is required".to_string()));
79 }
80
81 let approval_mode = read_approval_mode(&state)?;
83
84 if approval_mode {
85 let id = approval_queue::enqueue(
86 &state.db, "tweet", "", "", text, "", "", 0.0,
91 )
92 .await?;
93
94 let _ = state.event_tx.send(WsEvent::ApprovalQueued {
95 id,
96 action_type: "tweet".to_string(),
97 content: text.to_string(),
98 });
99
100 Ok(Json(json!({
101 "status": "queued_for_approval",
102 "id": id,
103 })))
104 } else {
105 Ok(Json(json!({
107 "status": "accepted",
108 "text": text,
109 "scheduled_for": body.scheduled_for,
110 })))
111 }
112}
113
114#[derive(Deserialize)]
116pub struct ComposeThreadRequest {
117 pub tweets: Vec<String>,
119 pub scheduled_for: Option<String>,
121}
122
123pub async fn compose_thread(
125 State(state): State<Arc<AppState>>,
126 Json(body): Json<ComposeThreadRequest>,
127) -> Result<Json<Value>, ApiError> {
128 if body.tweets.is_empty() {
129 return Err(ApiError::BadRequest(
130 "tweets array must not be empty".to_string(),
131 ));
132 }
133
134 let approval_mode = read_approval_mode(&state)?;
135 let combined = body.tweets.join("\n---\n");
136
137 if approval_mode {
138 let id =
139 approval_queue::enqueue(&state.db, "thread", "", "", &combined, "", "", 0.0).await?;
140
141 let _ = state.event_tx.send(WsEvent::ApprovalQueued {
142 id,
143 action_type: "thread".to_string(),
144 content: combined,
145 });
146
147 Ok(Json(json!({
148 "status": "queued_for_approval",
149 "id": id,
150 })))
151 } else {
152 Ok(Json(json!({
153 "status": "accepted",
154 "tweet_count": body.tweets.len(),
155 "scheduled_for": body.scheduled_for,
156 })))
157 }
158}
159
160#[derive(Debug, Serialize)]
166pub struct CalendarItem {
167 pub id: i64,
168 pub content_type: String,
169 pub content: String,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub target_author: Option<String>,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub topic: Option<String>,
174 pub timestamp: String,
175 pub status: String,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub performance_score: Option<f64>,
178 pub source: String,
179}
180
181#[derive(Deserialize)]
183pub struct CalendarQuery {
184 pub from: String,
186 pub to: String,
188}
189
190pub async fn calendar(
192 State(state): State<Arc<AppState>>,
193 Query(params): Query<CalendarQuery>,
194) -> Result<Json<Value>, ApiError> {
195 let from = ¶ms.from;
196 let to = ¶ms.to;
197
198 let mut items: Vec<CalendarItem> = Vec::new();
199
200 let tweets = threads::get_tweets_in_range(&state.db, from, to).await?;
202 for t in tweets {
203 items.push(CalendarItem {
204 id: t.id,
205 content_type: "tweet".to_string(),
206 content: t.content,
207 target_author: None,
208 topic: t.topic,
209 timestamp: t.created_at,
210 status: t.status,
211 performance_score: None,
212 source: "autonomous".to_string(),
213 });
214 }
215
216 let thread_list = threads::get_threads_in_range(&state.db, from, to).await?;
218 for t in thread_list {
219 items.push(CalendarItem {
220 id: t.id,
221 content_type: "thread".to_string(),
222 content: t.topic.clone(),
223 target_author: None,
224 topic: Some(t.topic),
225 timestamp: t.created_at,
226 status: t.status,
227 performance_score: None,
228 source: "autonomous".to_string(),
229 });
230 }
231
232 let reply_list = replies::get_replies_in_range(&state.db, from, to).await?;
234 for r in reply_list {
235 items.push(CalendarItem {
236 id: r.id,
237 content_type: "reply".to_string(),
238 content: r.reply_content,
239 target_author: Some(r.target_tweet_id),
240 topic: None,
241 timestamp: r.created_at,
242 status: r.status,
243 performance_score: None,
244 source: "autonomous".to_string(),
245 });
246 }
247
248 let pending = approval_queue::get_by_statuses(&state.db, &["pending"], None).await?;
250 for a in pending {
251 if a.created_at >= *from && a.created_at <= *to {
253 items.push(CalendarItem {
254 id: a.id,
255 content_type: a.action_type,
256 content: a.generated_content,
257 target_author: if a.target_author.is_empty() {
258 None
259 } else {
260 Some(a.target_author)
261 },
262 topic: if a.topic.is_empty() {
263 None
264 } else {
265 Some(a.topic)
266 },
267 timestamp: a.created_at,
268 status: "pending".to_string(),
269 performance_score: None,
270 source: "approval".to_string(),
271 });
272 }
273 }
274
275 let scheduled = scheduled_content::get_in_range(&state.db, from, to).await?;
277 for s in scheduled {
278 items.push(CalendarItem {
279 id: s.id,
280 content_type: s.content_type,
281 content: s.content,
282 target_author: None,
283 topic: None,
284 timestamp: s.scheduled_for.unwrap_or(s.created_at),
285 status: s.status,
286 performance_score: None,
287 source: "manual".to_string(),
288 });
289 }
290
291 items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
293
294 Ok(Json(json!(items)))
295}
296
297pub async fn schedule(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
299 let config = read_config(&state)?;
300
301 Ok(Json(json!({
302 "timezone": config.schedule.timezone,
303 "active_hours": {
304 "start": config.schedule.active_hours_start,
305 "end": config.schedule.active_hours_end,
306 },
307 "preferred_times": config.schedule.preferred_times,
308 "preferred_times_override": config.schedule.preferred_times_override,
309 "thread_day": config.schedule.thread_preferred_day,
310 "thread_time": config.schedule.thread_preferred_time,
311 })))
312}
313
314#[derive(Deserialize)]
316pub struct ComposeRequest {
317 pub content_type: String,
319 pub content: String,
321 pub scheduled_for: Option<String>,
323}
324
325pub async fn compose(
327 State(state): State<Arc<AppState>>,
328 Json(body): Json<ComposeRequest>,
329) -> Result<Json<Value>, ApiError> {
330 let content = body.content.trim().to_string();
331 if content.is_empty() {
332 return Err(ApiError::BadRequest("content is required".to_string()));
333 }
334
335 match body.content_type.as_str() {
336 "tweet" => {
337 if content.len() > 280 {
338 return Err(ApiError::BadRequest(
339 "tweet content must not exceed 280 characters".to_string(),
340 ));
341 }
342 }
343 "thread" => {
344 let tweets: Result<Vec<String>, _> = serde_json::from_str(&content);
346 match tweets {
347 Ok(ref t) if t.is_empty() => {
348 return Err(ApiError::BadRequest(
349 "thread must contain at least one tweet".to_string(),
350 ));
351 }
352 Ok(ref t) => {
353 for (i, tweet) in t.iter().enumerate() {
354 if tweet.len() > 280 {
355 return Err(ApiError::BadRequest(format!(
356 "tweet {} exceeds 280 characters",
357 i + 1
358 )));
359 }
360 }
361 }
362 Err(_) => {
363 return Err(ApiError::BadRequest(
364 "thread content must be a JSON array of strings".to_string(),
365 ));
366 }
367 }
368 }
369 _ => {
370 return Err(ApiError::BadRequest(
371 "content_type must be 'tweet' or 'thread'".to_string(),
372 ));
373 }
374 }
375
376 let approval_mode = read_approval_mode(&state)?;
377
378 if approval_mode {
379 let id =
380 approval_queue::enqueue(&state.db, &body.content_type, "", "", &content, "", "", 0.0)
381 .await?;
382
383 let _ = state.event_tx.send(WsEvent::ApprovalQueued {
384 id,
385 action_type: body.content_type,
386 content: content.clone(),
387 });
388
389 Ok(Json(json!({
390 "status": "queued_for_approval",
391 "id": id,
392 })))
393 } else {
394 let id = scheduled_content::insert(
395 &state.db,
396 &body.content_type,
397 &content,
398 body.scheduled_for.as_deref(),
399 )
400 .await?;
401
402 let _ = state.event_tx.send(WsEvent::ContentScheduled {
403 id,
404 content_type: body.content_type,
405 scheduled_for: body.scheduled_for,
406 });
407
408 Ok(Json(json!({
409 "status": "scheduled",
410 "id": id,
411 })))
412 }
413}
414
415#[derive(Deserialize)]
417pub struct EditScheduledRequest {
418 pub content: Option<String>,
420 pub scheduled_for: Option<String>,
422}
423
424pub async fn edit_scheduled(
426 State(state): State<Arc<AppState>>,
427 Path(id): Path<i64>,
428 Json(body): Json<EditScheduledRequest>,
429) -> Result<Json<Value>, ApiError> {
430 let item = scheduled_content::get_by_id(&state.db, id)
431 .await?
432 .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
433
434 if item.status != "scheduled" {
435 return Err(ApiError::BadRequest(
436 "can only edit items with status 'scheduled'".to_string(),
437 ));
438 }
439
440 let new_content = body.content.as_deref().unwrap_or(&item.content);
441 let new_scheduled_for = match &body.scheduled_for {
442 Some(t) => Some(t.as_str()),
443 None => item.scheduled_for.as_deref(),
444 };
445
446 scheduled_content::update_content(&state.db, id, new_content, new_scheduled_for).await?;
447
448 let updated = scheduled_content::get_by_id(&state.db, id)
449 .await?
450 .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
451
452 Ok(Json(json!(updated)))
453}
454
455pub async fn cancel_scheduled(
457 State(state): State<Arc<AppState>>,
458 Path(id): Path<i64>,
459) -> Result<Json<Value>, ApiError> {
460 let item = scheduled_content::get_by_id(&state.db, id)
461 .await?
462 .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
463
464 if item.status != "scheduled" {
465 return Err(ApiError::BadRequest(
466 "can only cancel items with status 'scheduled'".to_string(),
467 ));
468 }
469
470 scheduled_content::cancel(&state.db, id).await?;
471
472 Ok(Json(json!({
473 "status": "cancelled",
474 "id": id,
475 })))
476}
477
478fn read_approval_mode(state: &AppState) -> Result<bool, ApiError> {
484 let config = read_config(state)?;
485 Ok(config.approval_mode)
486}
487
488fn read_config(state: &AppState) -> Result<Config, ApiError> {
490 let contents = std::fs::read_to_string(&state.config_path).unwrap_or_default();
491 let config: Config = toml::from_str(&contents).unwrap_or_default();
492 Ok(config)
493}