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::content::{tweet_weighted_len, MAX_TWEET_CHARS};
11use tuitbot_core::storage::{approval_queue, replies, scheduled_content, threads};
12
13use crate::error::ApiError;
14use crate::state::AppState;
15use crate::ws::WsEvent;
16
17#[derive(Deserialize)]
23pub struct TweetsQuery {
24 #[serde(default = "default_tweet_limit")]
26 pub limit: u32,
27}
28
29fn default_tweet_limit() -> u32 {
30 50
31}
32
33#[derive(Deserialize)]
35pub struct ThreadsQuery {
36 #[serde(default = "default_thread_limit")]
38 pub limit: u32,
39}
40
41fn default_thread_limit() -> u32 {
42 20
43}
44
45pub async fn list_tweets(
47 State(state): State<Arc<AppState>>,
48 Query(params): Query<TweetsQuery>,
49) -> Result<Json<Value>, ApiError> {
50 let tweets = threads::get_recent_original_tweets(&state.db, params.limit).await?;
51 Ok(Json(json!(tweets)))
52}
53
54pub async fn list_threads(
56 State(state): State<Arc<AppState>>,
57 Query(params): Query<ThreadsQuery>,
58) -> Result<Json<Value>, ApiError> {
59 let threads = threads::get_recent_threads(&state.db, params.limit).await?;
60 Ok(Json(json!(threads)))
61}
62
63#[derive(Deserialize)]
65pub struct ComposeTweetRequest {
66 pub text: String,
68 pub scheduled_for: Option<String>,
70}
71
72pub async fn compose_tweet(
74 State(state): State<Arc<AppState>>,
75 Json(body): Json<ComposeTweetRequest>,
76) -> Result<Json<Value>, ApiError> {
77 let text = body.text.trim();
78 if text.is_empty() {
79 return Err(ApiError::BadRequest("text is required".to_string()));
80 }
81
82 let approval_mode = read_approval_mode(&state)?;
84
85 if approval_mode {
86 let id = approval_queue::enqueue(
87 &state.db, "tweet", "", "", text, "", "", 0.0, "[]",
92 )
93 .await?;
94
95 let _ = state.event_tx.send(WsEvent::ApprovalQueued {
96 id,
97 action_type: "tweet".to_string(),
98 content: text.to_string(),
99 media_paths: vec![],
100 });
101
102 Ok(Json(json!({
103 "status": "queued_for_approval",
104 "id": id,
105 })))
106 } else {
107 Ok(Json(json!({
109 "status": "accepted",
110 "text": text,
111 "scheduled_for": body.scheduled_for,
112 })))
113 }
114}
115
116#[derive(Deserialize)]
118pub struct ComposeThreadRequest {
119 pub tweets: Vec<String>,
121 pub scheduled_for: Option<String>,
123}
124
125pub async fn compose_thread(
127 State(state): State<Arc<AppState>>,
128 Json(body): Json<ComposeThreadRequest>,
129) -> Result<Json<Value>, ApiError> {
130 if body.tweets.is_empty() {
131 return Err(ApiError::BadRequest(
132 "tweets array must not be empty".to_string(),
133 ));
134 }
135
136 let approval_mode = read_approval_mode(&state)?;
137 let combined = body.tweets.join("\n---\n");
138
139 if approval_mode {
140 let id = approval_queue::enqueue(&state.db, "thread", "", "", &combined, "", "", 0.0, "[]")
141 .await?;
142
143 let _ = state.event_tx.send(WsEvent::ApprovalQueued {
144 id,
145 action_type: "thread".to_string(),
146 content: combined,
147 media_paths: vec![],
148 });
149
150 Ok(Json(json!({
151 "status": "queued_for_approval",
152 "id": id,
153 })))
154 } else {
155 Ok(Json(json!({
156 "status": "accepted",
157 "tweet_count": body.tweets.len(),
158 "scheduled_for": body.scheduled_for,
159 })))
160 }
161}
162
163#[derive(Debug, Serialize)]
169pub struct CalendarItem {
170 pub id: i64,
171 pub content_type: String,
172 pub content: String,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub target_author: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub topic: Option<String>,
177 pub timestamp: String,
178 pub status: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub performance_score: Option<f64>,
181 pub source: String,
182}
183
184#[derive(Deserialize)]
186pub struct CalendarQuery {
187 pub from: String,
189 pub to: String,
191}
192
193pub async fn calendar(
195 State(state): State<Arc<AppState>>,
196 Query(params): Query<CalendarQuery>,
197) -> Result<Json<Value>, ApiError> {
198 let from = ¶ms.from;
199 let to = ¶ms.to;
200
201 let mut items: Vec<CalendarItem> = Vec::new();
202
203 let tweets = threads::get_tweets_in_range(&state.db, from, to).await?;
205 for t in tweets {
206 items.push(CalendarItem {
207 id: t.id,
208 content_type: "tweet".to_string(),
209 content: t.content,
210 target_author: None,
211 topic: t.topic,
212 timestamp: t.created_at,
213 status: t.status,
214 performance_score: None,
215 source: "autonomous".to_string(),
216 });
217 }
218
219 let thread_list = threads::get_threads_in_range(&state.db, from, to).await?;
221 for t in thread_list {
222 items.push(CalendarItem {
223 id: t.id,
224 content_type: "thread".to_string(),
225 content: t.topic.clone(),
226 target_author: None,
227 topic: Some(t.topic),
228 timestamp: t.created_at,
229 status: t.status,
230 performance_score: None,
231 source: "autonomous".to_string(),
232 });
233 }
234
235 let reply_list = replies::get_replies_in_range(&state.db, from, to).await?;
237 for r in reply_list {
238 items.push(CalendarItem {
239 id: r.id,
240 content_type: "reply".to_string(),
241 content: r.reply_content,
242 target_author: Some(r.target_tweet_id),
243 topic: None,
244 timestamp: r.created_at,
245 status: r.status,
246 performance_score: None,
247 source: "autonomous".to_string(),
248 });
249 }
250
251 let pending = approval_queue::get_by_statuses(&state.db, &["pending"], None).await?;
253 for a in pending {
254 if a.created_at >= *from && a.created_at <= *to {
256 items.push(CalendarItem {
257 id: a.id,
258 content_type: a.action_type,
259 content: a.generated_content,
260 target_author: if a.target_author.is_empty() {
261 None
262 } else {
263 Some(a.target_author)
264 },
265 topic: if a.topic.is_empty() {
266 None
267 } else {
268 Some(a.topic)
269 },
270 timestamp: a.created_at,
271 status: "pending".to_string(),
272 performance_score: None,
273 source: "approval".to_string(),
274 });
275 }
276 }
277
278 let scheduled = scheduled_content::get_in_range(&state.db, from, to).await?;
280 for s in scheduled {
281 items.push(CalendarItem {
282 id: s.id,
283 content_type: s.content_type,
284 content: s.content,
285 target_author: None,
286 topic: None,
287 timestamp: s.scheduled_for.unwrap_or(s.created_at),
288 status: s.status,
289 performance_score: None,
290 source: "manual".to_string(),
291 });
292 }
293
294 items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
296
297 Ok(Json(json!(items)))
298}
299
300pub async fn schedule(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
302 let config = read_config(&state)?;
303
304 Ok(Json(json!({
305 "timezone": config.schedule.timezone,
306 "active_hours": {
307 "start": config.schedule.active_hours_start,
308 "end": config.schedule.active_hours_end,
309 },
310 "preferred_times": config.schedule.preferred_times,
311 "preferred_times_override": config.schedule.preferred_times_override,
312 "thread_day": config.schedule.thread_preferred_day,
313 "thread_time": config.schedule.thread_preferred_time,
314 })))
315}
316
317#[derive(Deserialize)]
319pub struct ComposeRequest {
320 pub content_type: String,
322 pub content: String,
324 pub scheduled_for: Option<String>,
326 #[serde(default)]
328 pub media_paths: Option<Vec<String>>,
329}
330
331pub async fn compose(
333 State(state): State<Arc<AppState>>,
334 Json(body): Json<ComposeRequest>,
335) -> Result<Json<Value>, ApiError> {
336 let content = body.content.trim().to_string();
337 if content.is_empty() {
338 return Err(ApiError::BadRequest("content is required".to_string()));
339 }
340
341 match body.content_type.as_str() {
342 "tweet" => {
343 if tweet_weighted_len(&content) > MAX_TWEET_CHARS {
344 return Err(ApiError::BadRequest(
345 "tweet content must not exceed 280 characters".to_string(),
346 ));
347 }
348 }
349 "thread" => {
350 let tweets: Result<Vec<String>, _> = serde_json::from_str(&content);
352 match tweets {
353 Ok(ref t) if t.is_empty() => {
354 return Err(ApiError::BadRequest(
355 "thread must contain at least one tweet".to_string(),
356 ));
357 }
358 Ok(ref t) => {
359 for (i, tweet) in t.iter().enumerate() {
360 if tweet_weighted_len(tweet) > MAX_TWEET_CHARS {
361 return Err(ApiError::BadRequest(format!(
362 "tweet {} exceeds 280 characters",
363 i + 1
364 )));
365 }
366 }
367 }
368 Err(_) => {
369 return Err(ApiError::BadRequest(
370 "thread content must be a JSON array of strings".to_string(),
371 ));
372 }
373 }
374 }
375 _ => {
376 return Err(ApiError::BadRequest(
377 "content_type must be 'tweet' or 'thread'".to_string(),
378 ));
379 }
380 }
381
382 let approval_mode = read_approval_mode(&state)?;
383
384 if approval_mode {
385 let media_paths = body.media_paths.as_deref().unwrap_or(&[]);
386 let media_json = serde_json::to_string(media_paths).unwrap_or_else(|_| "[]".to_string());
387 let id = approval_queue::enqueue(
388 &state.db,
389 &body.content_type,
390 "",
391 "",
392 &content,
393 "",
394 "",
395 0.0,
396 &media_json,
397 )
398 .await?;
399
400 let _ = state.event_tx.send(WsEvent::ApprovalQueued {
401 id,
402 action_type: body.content_type,
403 content: content.clone(),
404 media_paths: media_paths.to_vec(),
405 });
406
407 Ok(Json(json!({
408 "status": "queued_for_approval",
409 "id": id,
410 })))
411 } else {
412 let id = scheduled_content::insert(
413 &state.db,
414 &body.content_type,
415 &content,
416 body.scheduled_for.as_deref(),
417 )
418 .await?;
419
420 let _ = state.event_tx.send(WsEvent::ContentScheduled {
421 id,
422 content_type: body.content_type,
423 scheduled_for: body.scheduled_for,
424 });
425
426 Ok(Json(json!({
427 "status": "scheduled",
428 "id": id,
429 })))
430 }
431}
432
433#[derive(Deserialize)]
435pub struct EditScheduledRequest {
436 pub content: Option<String>,
438 pub scheduled_for: Option<String>,
440}
441
442pub async fn edit_scheduled(
444 State(state): State<Arc<AppState>>,
445 Path(id): Path<i64>,
446 Json(body): Json<EditScheduledRequest>,
447) -> Result<Json<Value>, ApiError> {
448 let item = scheduled_content::get_by_id(&state.db, id)
449 .await?
450 .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
451
452 if item.status != "scheduled" {
453 return Err(ApiError::BadRequest(
454 "can only edit items with status 'scheduled'".to_string(),
455 ));
456 }
457
458 let new_content = body.content.as_deref().unwrap_or(&item.content);
459 let new_scheduled_for = match &body.scheduled_for {
460 Some(t) => Some(t.as_str()),
461 None => item.scheduled_for.as_deref(),
462 };
463
464 scheduled_content::update_content(&state.db, id, new_content, new_scheduled_for).await?;
465
466 let updated = scheduled_content::get_by_id(&state.db, id)
467 .await?
468 .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
469
470 Ok(Json(json!(updated)))
471}
472
473pub async fn cancel_scheduled(
475 State(state): State<Arc<AppState>>,
476 Path(id): Path<i64>,
477) -> Result<Json<Value>, ApiError> {
478 let item = scheduled_content::get_by_id(&state.db, id)
479 .await?
480 .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
481
482 if item.status != "scheduled" {
483 return Err(ApiError::BadRequest(
484 "can only cancel items with status 'scheduled'".to_string(),
485 ));
486 }
487
488 scheduled_content::cancel(&state.db, id).await?;
489
490 Ok(Json(json!({
491 "status": "cancelled",
492 "id": id,
493 })))
494}
495
496fn read_approval_mode(state: &AppState) -> Result<bool, ApiError> {
502 let config = read_config(state)?;
503 Ok(config.effective_approval_mode())
504}
505
506fn read_config(state: &AppState) -> Result<Config, ApiError> {
508 let contents = std::fs::read_to_string(&state.config_path).unwrap_or_default();
509 let config: Config = toml::from_str(&contents).unwrap_or_default();
510 Ok(config)
511}
512
513#[derive(Deserialize)]
518pub struct CreateDraftRequest {
519 pub content_type: String,
520 pub content: String,
521 #[serde(default = "default_source")]
522 pub source: String,
523}
524
525fn default_source() -> String {
526 "manual".to_string()
527}
528
529pub async fn list_drafts(
530 State(state): State<Arc<AppState>>,
531) -> Result<Json<Vec<scheduled_content::ScheduledContent>>, ApiError> {
532 let drafts = scheduled_content::list_drafts(&state.db)
533 .await
534 .map_err(ApiError::Storage)?;
535 Ok(Json(drafts))
536}
537
538pub async fn create_draft(
539 State(state): State<Arc<AppState>>,
540 Json(body): Json<CreateDraftRequest>,
541) -> Result<Json<Value>, ApiError> {
542 if body.content.trim().is_empty() {
544 return Err(ApiError::BadRequest(
545 "content must not be empty".to_string(),
546 ));
547 }
548
549 if body.content_type == "tweet"
550 && !tuitbot_core::content::validate_tweet_length(&body.content, MAX_TWEET_CHARS)
551 {
552 return Err(ApiError::BadRequest(format!(
553 "Tweet exceeds {} characters (weighted length: {})",
554 MAX_TWEET_CHARS,
555 tweet_weighted_len(&body.content)
556 )));
557 }
558
559 let id =
560 scheduled_content::insert_draft(&state.db, &body.content_type, &body.content, &body.source)
561 .await
562 .map_err(ApiError::Storage)?;
563
564 Ok(Json(json!({ "id": id, "status": "draft" })))
565}
566
567#[derive(Deserialize)]
568pub struct EditDraftRequest {
569 pub content: String,
570}
571
572pub async fn edit_draft(
573 State(state): State<Arc<AppState>>,
574 Path(id): Path<i64>,
575 Json(body): Json<EditDraftRequest>,
576) -> Result<Json<Value>, ApiError> {
577 if body.content.trim().is_empty() {
578 return Err(ApiError::BadRequest(
579 "content must not be empty".to_string(),
580 ));
581 }
582
583 scheduled_content::update_draft(&state.db, id, &body.content)
584 .await
585 .map_err(ApiError::Storage)?;
586
587 Ok(Json(json!({ "id": id, "status": "draft" })))
588}
589
590pub async fn delete_draft(
591 State(state): State<Arc<AppState>>,
592 Path(id): Path<i64>,
593) -> Result<Json<Value>, ApiError> {
594 scheduled_content::delete_draft(&state.db, id)
595 .await
596 .map_err(ApiError::Storage)?;
597
598 Ok(Json(json!({ "id": id, "status": "cancelled" })))
599}
600
601#[derive(Deserialize)]
602pub struct ScheduleDraftRequest {
603 pub scheduled_for: String,
604}
605
606pub async fn schedule_draft(
607 State(state): State<Arc<AppState>>,
608 Path(id): Path<i64>,
609 Json(body): Json<ScheduleDraftRequest>,
610) -> Result<Json<Value>, ApiError> {
611 scheduled_content::schedule_draft(&state.db, id, &body.scheduled_for)
612 .await
613 .map_err(ApiError::Storage)?;
614
615 Ok(Json(
616 json!({ "id": id, "status": "scheduled", "scheduled_for": body.scheduled_for }),
617 ))
618}
619
620pub async fn publish_draft(
621 State(state): State<Arc<AppState>>,
622 Path(id): Path<i64>,
623) -> Result<Json<Value>, ApiError> {
624 let item = scheduled_content::get_by_id(&state.db, id)
626 .await
627 .map_err(ApiError::Storage)?
628 .ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
629
630 if item.status != "draft" {
631 return Err(ApiError::BadRequest(format!(
632 "Item is in '{}' status, not 'draft'",
633 item.status
634 )));
635 }
636
637 let queue_id = approval_queue::enqueue(
639 &state.db,
640 &item.content_type,
641 "", "", &item.content,
644 "", "", 0.0, "[]",
648 )
649 .await
650 .map_err(ApiError::Storage)?;
651
652 approval_queue::update_status(&state.db, queue_id, "approved")
654 .await
655 .map_err(ApiError::Storage)?;
656
657 scheduled_content::update_status(&state.db, id, "posted", None)
659 .await
660 .map_err(ApiError::Storage)?;
661
662 Ok(Json(
663 json!({ "id": id, "approval_queue_id": queue_id, "status": "queued_for_posting" }),
664 ))
665}