tuitbot_core/automation/posting_queue/queue.rs
1//! Serialized posting queue for concurrent automation loops.
2//!
3//! All loops funnel post actions through a single bounded MPSC channel,
4//! preventing race conditions and ensuring rate limits are respected
5//! globally. A single consumer task processes actions sequentially with
6//! configurable delays between posts.
7
8use tokio::sync::{mpsc, oneshot};
9
10/// Default bounded channel capacity for the posting queue.
11pub const QUEUE_CAPACITY: usize = 100;
12
13/// An action to be executed by the posting queue consumer.
14///
15/// Each variant optionally includes a oneshot sender so the caller can
16/// await the result (e.g., the posted tweet ID or an error message).
17pub enum PostAction {
18 /// Reply to an existing tweet.
19 Reply {
20 /// The ID of the tweet to reply to.
21 tweet_id: String,
22 /// The reply content.
23 content: String,
24 /// Media IDs to attach (already uploaded to X API).
25 media_ids: Vec<String>,
26 /// Optional channel to receive the result (posted tweet ID or error).
27 result_tx: Option<oneshot::Sender<Result<String, String>>>,
28 },
29 /// Post a new original tweet.
30 Tweet {
31 /// The tweet content.
32 content: String,
33 /// Media IDs to attach (already uploaded to X API).
34 media_ids: Vec<String>,
35 /// Optional channel to receive the result.
36 result_tx: Option<oneshot::Sender<Result<String, String>>>,
37 },
38 /// Post a tweet as part of a thread (reply to previous tweet in thread).
39 ThreadTweet {
40 /// The tweet content.
41 content: String,
42 /// The ID of the previous tweet in the thread.
43 in_reply_to: String,
44 /// Media IDs to attach (already uploaded to X API).
45 media_ids: Vec<String>,
46 /// Optional channel to receive the result.
47 result_tx: Option<oneshot::Sender<Result<String, String>>>,
48 },
49}
50
51impl std::fmt::Debug for PostAction {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 PostAction::Reply {
55 tweet_id,
56 content,
57 media_ids,
58 ..
59 } => f
60 .debug_struct("Reply")
61 .field("tweet_id", tweet_id)
62 .field("content_len", &content.len())
63 .field("media_count", &media_ids.len())
64 .finish(),
65 PostAction::Tweet {
66 content, media_ids, ..
67 } => f
68 .debug_struct("Tweet")
69 .field("content_len", &content.len())
70 .field("media_count", &media_ids.len())
71 .finish(),
72 PostAction::ThreadTweet {
73 content,
74 in_reply_to,
75 media_ids,
76 ..
77 } => f
78 .debug_struct("ThreadTweet")
79 .field("in_reply_to", in_reply_to)
80 .field("content_len", &content.len())
81 .field("media_count", &media_ids.len())
82 .finish(),
83 }
84 }
85}
86
87/// Trait for executing post actions against the X API.
88///
89/// This trait decouples the posting queue from the actual API client,
90/// allowing the queue to be tested with mock executors.
91#[async_trait::async_trait]
92pub trait PostExecutor: Send + Sync {
93 /// Post a reply to a specific tweet. Returns the posted tweet ID.
94 async fn execute_reply(
95 &self,
96 tweet_id: &str,
97 content: &str,
98 media_ids: &[String],
99 ) -> Result<String, String>;
100
101 /// Post a new original tweet. Returns the posted tweet ID.
102 async fn execute_tweet(&self, content: &str, media_ids: &[String]) -> Result<String, String>;
103}
104
105/// Create a bounded posting queue channel.
106///
107/// Returns `(sender, receiver)`. Clone the sender for each automation loop.
108/// Pass the receiver to [`run_posting_queue`].
109pub fn create_posting_queue() -> (mpsc::Sender<PostAction>, mpsc::Receiver<PostAction>) {
110 mpsc::channel(QUEUE_CAPACITY)
111}
112
113/// Trait for queueing actions for human approval instead of posting.
114#[async_trait::async_trait]
115pub trait ApprovalQueue: Send + Sync {
116 /// Queue a reply for human review. Returns the queue item ID.
117 async fn queue_reply(
118 &self,
119 tweet_id: &str,
120 content: &str,
121 media_paths: &[String],
122 ) -> Result<i64, String>;
123
124 /// Queue a tweet for human review. Returns the queue item ID.
125 async fn queue_tweet(&self, content: &str, media_paths: &[String]) -> Result<i64, String>;
126}