1+ use clap:: Parser ;
2+ use colored:: Colorize ;
3+ use std:: path:: PathBuf ;
4+ use crate :: config:: ConfigManager ;
5+ use crate :: error:: Result ;
6+ use twitter_api:: { TwitterApi , TwitterClient , TwitterError } ;
7+ use chrono:: { Duration as ChronoDuration , Utc } ;
8+ use chrono_tz;
9+ use crate :: social:: AnnouncementFormatter ;
10+ use crate :: twitter_utils:: post_tweet_flexible;
11+
12+ #[ derive( Parser ) ]
13+ pub struct PostTargetFrameArgs {
14+ /// Tweet ID to reply to (the #commitmentsopen tweet)
15+ #[ arg( short, long) ]
16+ pub reply_to : String ,
17+
18+ /// Target frame image file to post
19+ #[ arg( short, long) ]
20+ pub image : PathBuf ,
21+
22+ /// Round number
23+ #[ arg( long) ]
24+ pub round : u64 ,
25+
26+ /// Target time in hours from now
27+ #[ arg( long) ]
28+ pub target_time : u64 ,
29+
30+ /// Show verbose output
31+ #[ arg( short, long) ]
32+ pub verbose : bool ,
33+
34+ /// Suppress colored output (useful for scripts/logging)
35+ #[ arg( long) ]
36+ pub no_color : bool ,
37+
38+ /// Quiet mode - only output the tweet data
39+ #[ arg( short, long) ]
40+ pub quiet : bool ,
41+
42+ /// Config file path (default: config/llm.yaml)
43+ #[ arg( long, default_value = "config/llm.yaml" ) ]
44+ pub config : String ,
45+ }
46+
47+ #[ derive( serde:: Serialize , serde:: Deserialize , Clone ) ]
48+ struct PostTargetFrameResults {
49+ tweet_id : String ,
50+ tweet_url : String ,
51+ reply_to_tweet_id : String ,
52+ image_path : String ,
53+ round_number : u64 ,
54+ target_time : String ,
55+ posted_at : String ,
56+ }
57+
58+ pub async fn run ( args : PostTargetFrameArgs ) -> Result < ( ) > {
59+ // Initialize colored output
60+ if args. no_color || args. quiet {
61+ colored:: control:: set_override ( false ) ;
62+ }
63+
64+ if args. verbose {
65+ println ! ( "Starting target frame posting..." ) ;
66+ println ! ( "Replying to tweet: {}" , args. reply_to) ;
67+ println ! ( "Image file: {}" , args. image. display( ) ) ;
68+ println ! ( "Round: {}" , args. round) ;
69+ println ! ( "Target time: {} hours from now" , args. target_time) ;
70+ }
71+
72+ // Validate image file exists
73+ if !args. image . exists ( ) {
74+ return Err ( format ! ( "Image file does not exist: {}" , args. image. display( ) ) . into ( ) ) ;
75+ }
76+
77+ // Load config
78+ let config_manager = ConfigManager :: with_path ( & args. config )
79+ . map_err ( |e| format ! ( "Failed to load config file: {}" , e) ) ?;
80+ let config = config_manager. get_config ( ) . clone ( ) ;
81+ let twitter = & config. twitter ;
82+
83+ if !args. quiet {
84+ println ! ( "✅ Loaded config from: {}" , & args. config) ;
85+ }
86+
87+ // Create TwitterClient
88+ let twitter_config = twitter_api:: TwitterConfig {
89+ api_key : twitter. api_key . clone ( ) ,
90+ api_secret : twitter. api_secret . clone ( ) ,
91+ access_token : twitter. access_token . clone ( ) ,
92+ access_token_secret : twitter. access_token_secret . clone ( ) ,
93+ } ;
94+ let client = TwitterClient :: new ( twitter_config) ;
95+
96+ // Calculate target time (hours from now)
97+ let target_time = Utc :: now ( ) + ChronoDuration :: hours ( args. target_time as i64 ) ;
98+
99+ // Format target time as "2025-04-01 | 16:30:57 | EST"
100+ let eastern = chrono_tz:: US :: Eastern ;
101+ let target_time_eastern = target_time. with_timezone ( & eastern) ;
102+ let formatted_target_time = format ! (
103+ "{} | {} | EST" ,
104+ target_time_eastern. format( "%Y-%m-%d" ) ,
105+ target_time_eastern. format( "%H:%M:%S" )
106+ ) ;
107+
108+ // Create announcement data for reveals
109+ let announcement_data = crate :: social:: AnnouncementData {
110+ round_id : args. round ,
111+ state_name : "revealsopen" . to_string ( ) ,
112+ target_time : formatted_target_time. clone ( ) ,
113+ hashtags : vec ! [ ] , // The formatter will add standard hashtags
114+ message : String :: new ( ) , // Not used for reveal announcements
115+ prize_pool : None ,
116+ livestream_url : None , // Optional for reveals
117+ } ;
118+
119+ // Format the reveals announcement
120+ let formatter = AnnouncementFormatter :: new ( ) ;
121+ let tweet_text = formatter. create_reveals_announcement ( & announcement_data) ;
122+
123+ if args. verbose {
124+ println ! ( "Tweet text: {}" , tweet_text) ;
125+ println ! ( "Image to upload: {}" , args. image. display( ) ) ;
126+ }
127+
128+ // Post the tweet with image as reply
129+ let result = post_tweet_flexible (
130+ & client,
131+ & tweet_text,
132+ Some ( & args. reply_to ) ,
133+ Some ( args. image . clone ( ) ) ,
134+ )
135+ . await ;
136+
137+ match result {
138+ Ok ( post_result) => {
139+ let tweet = & post_result. tweet ;
140+
141+ let results = PostTargetFrameResults {
142+ tweet_id : tweet. id . clone ( ) ,
143+ tweet_url : format ! ( "https://twitter.com/i/status/{}" , tweet. id) ,
144+ reply_to_tweet_id : args. reply_to . clone ( ) ,
145+ image_path : args. image . display ( ) . to_string ( ) ,
146+ round_number : args. round ,
147+ target_time : formatted_target_time,
148+ posted_at : Utc :: now ( ) . to_rfc3339 ( ) ,
149+ } ;
150+
151+ if !args. quiet {
152+ println ! ( "✅ Target frame posted successfully!" ) ;
153+ println ! ( "Tweet ID: {}" , results. tweet_id) ;
154+ println ! ( "URL: {}" , results. tweet_url) ;
155+ println ! ( "Reply to: {}" , results. reply_to_tweet_id) ;
156+ println ! ( "Round: {}" , results. round_number) ;
157+ println ! ( "Target time: {}" , results. target_time) ;
158+ }
159+
160+ if args. verbose {
161+ println ! ( "Created: {:?}" , tweet. created_at) ;
162+ println ! ( "Text: {}" , tweet. text) ;
163+ if let Some ( metrics) = & tweet. public_metrics {
164+ println ! (
165+ "Initial metrics: {} retweets, {} likes, {} replies" ,
166+ metrics. retweet_count, metrics. like_count, metrics. reply_count
167+ ) ;
168+ }
169+ }
170+
171+ // Output JSON if quiet mode
172+ if args. quiet {
173+ println ! ( "{}" , serde_json:: to_string_pretty( & results) ?) ;
174+ }
175+
176+ Ok ( ( ) )
177+ }
178+ Err ( TwitterError :: ApiError { status, message } ) => {
179+ let error_msg = format ! ( "Twitter API error: {} - {}" , status, message) ;
180+ if args. quiet {
181+ eprintln ! ( "{}" , error_msg) ;
182+ } else {
183+ println ! ( "❌ {}" , error_msg) ;
184+ }
185+ Err ( error_msg. into ( ) )
186+ }
187+ Err ( TwitterError :: NetworkError ( e) ) => {
188+ let error_msg = format ! ( "Network error: {}" , e) ;
189+ if args. quiet {
190+ eprintln ! ( "{}" , error_msg) ;
191+ } else {
192+ println ! ( "❌ {}" , error_msg) ;
193+ }
194+ Err ( error_msg. into ( ) )
195+ }
196+ Err ( TwitterError :: AuthError ( e) ) => {
197+ let error_msg = format ! ( "Authentication error: {}" , e) ;
198+ if args. quiet {
199+ eprintln ! ( "{}" , error_msg) ;
200+ } else {
201+ println ! ( "❌ {}" , error_msg) ;
202+ }
203+ Err ( error_msg. into ( ) )
204+ }
205+ Err ( TwitterError :: ParseError ( e) ) => {
206+ let error_msg = format ! ( "Response parsing error: {}" , e) ;
207+ if args. quiet {
208+ eprintln ! ( "{}" , error_msg) ;
209+ } else {
210+ println ! ( "❌ {}" , error_msg) ;
211+ }
212+ Err ( error_msg. into ( ) )
213+ }
214+ Err ( TwitterError :: MediaError ( e) ) => {
215+ let error_msg = format ! ( "Media upload error: {}" , e) ;
216+ if args. quiet {
217+ eprintln ! ( "{}" , error_msg) ;
218+ } else {
219+ println ! ( "❌ {}" , error_msg) ;
220+ }
221+ Err ( error_msg. into ( ) )
222+ }
223+ Err ( TwitterError :: InvalidInput ( e) ) => {
224+ let error_msg = format ! ( "Invalid input: {}" , e) ;
225+ if args. quiet {
226+ eprintln ! ( "{}" , error_msg) ;
227+ } else {
228+ println ! ( "❌ {}" , error_msg) ;
229+ }
230+ Err ( error_msg. into ( ) )
231+ }
232+ Err ( TwitterError :: FileError ( e) ) => {
233+ let error_msg = format ! ( "File error: {}" , e) ;
234+ if args. quiet {
235+ eprintln ! ( "{}" , error_msg) ;
236+ } else {
237+ println ! ( "❌ {}" , error_msg) ;
238+ }
239+ Err ( error_msg. into ( ) )
240+ }
241+ Err ( TwitterError :: HttpError ( e) ) => {
242+ let error_msg = format ! ( "HTTP error: {}" , e) ;
243+ if args. quiet {
244+ eprintln ! ( "{}" , error_msg) ;
245+ } else {
246+ println ! ( "❌ {}" , error_msg) ;
247+ }
248+ Err ( error_msg. into ( ) )
249+ }
250+ Err ( TwitterError :: SerializationError ( e) ) => {
251+ let error_msg = format ! ( "Serialization error: {}" , e) ;
252+ if args. quiet {
253+ eprintln ! ( "{}" , error_msg) ;
254+ } else {
255+ println ! ( "❌ {}" , error_msg) ;
256+ }
257+ Err ( error_msg. into ( ) )
258+ }
259+ }
260+ }
261+
262+ #[ cfg( test) ]
263+ mod tests {
264+ use super :: * ;
265+ use std:: path:: PathBuf ;
266+
267+ #[ test]
268+ fn test_post_target_frame_args_parsing ( ) {
269+ let args = PostTargetFrameArgs :: try_parse_from ( & [
270+ "post-target-frame" ,
271+ "--reply-to" , "123456789" ,
272+ "--image" , "/path/to/image.jpg" ,
273+ "--round" , "5" ,
274+ "--target-time" , "2" ,
275+ ] ) . unwrap ( ) ;
276+
277+ assert_eq ! ( args. reply_to, "123456789" ) ;
278+ assert_eq ! ( args. image, PathBuf :: from( "/path/to/image.jpg" ) ) ;
279+ assert_eq ! ( args. round, 5 ) ;
280+ assert_eq ! ( args. target_time, 2 ) ;
281+ assert ! ( !args. verbose) ;
282+ assert ! ( !args. no_color) ;
283+ assert ! ( !args. quiet) ;
284+ assert_eq ! ( args. config, "config/llm.yaml" ) ;
285+ }
286+
287+ #[ test]
288+ fn test_post_target_frame_args_with_options ( ) {
289+ let args = PostTargetFrameArgs :: try_parse_from ( & [
290+ "post-target-frame" ,
291+ "--reply-to" , "123456789" ,
292+ "--image" , "/path/to/image.jpg" ,
293+ "--round" , "5" ,
294+ "--target-time" , "2" ,
295+ "--verbose" ,
296+ "--no-color" ,
297+ "--quiet" ,
298+ "--config" , "custom_config.yaml" ,
299+ ] ) . unwrap ( ) ;
300+
301+ assert_eq ! ( args. reply_to, "123456789" ) ;
302+ assert_eq ! ( args. image, PathBuf :: from( "/path/to/image.jpg" ) ) ;
303+ assert_eq ! ( args. round, 5 ) ;
304+ assert_eq ! ( args. target_time, 2 ) ;
305+ assert ! ( args. verbose) ;
306+ assert ! ( args. no_color) ;
307+ assert ! ( args. quiet) ;
308+ assert_eq ! ( args. config, "custom_config.yaml" ) ;
309+ }
310+
311+ #[ test]
312+ fn test_post_target_frame_args_missing_required ( ) {
313+ let result = PostTargetFrameArgs :: try_parse_from ( & [
314+ "post-target-frame" ,
315+ "--reply-to" , "123456789" ,
316+ // Missing --image
317+ "--round" , "5" ,
318+ "--target-time" , "2" ,
319+ ] ) ;
320+ assert ! ( result. is_err( ) ) ;
321+ }
322+ }
0 commit comments