Skip to content

Commit cea178e

Browse files
committed
Merge branch 'release/0.6.3'
2 parents 480a461 + d96b3e4 commit cea178e

File tree

7 files changed

+384
-19
lines changed

7 files changed

+384
-19
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ resolver = "2"
88

99
[package]
1010
name = "cliptions-core"
11-
version = "0.6.2"
11+
version = "0.6.3"
1212
edition = "2021"
1313
description = "Cliptions prediction market core functionality implemented in Rust with optional Python bindings"
1414
license = "MIT"

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,39 @@ Total replies found: 3
203203
- **Verbose mode**: Detailed output with metrics and conversation IDs
204204
- **Error handling**: Comprehensive error messages for API issues
205205

206+
### Target Frame Posting
207+
208+
Post target frame images as replies to commitment tweets:
209+
210+
```bash
211+
# Basic target frame posting
212+
cliptions post-target-frame --reply-to "1234567890123456789" --image "rounds/round2/target.jpg" --round 3 --target-time 2
213+
214+
# Verbose output with detailed information
215+
cliptions post-target-frame --reply-to "1234567890123456789" --image "rounds/round2/target.jpg" --round 3 --target-time 2 --verbose
216+
217+
# Use custom config file
218+
cliptions post-target-frame --reply-to "1234567890123456789" --image "rounds/round2/target.jpg" --round 3 --target-time 2 --config config/custom.yaml
219+
```
220+
221+
**Example Output:**
222+
```
223+
✅ Loaded config from: config/llm.yaml
224+
✅ Target frame posted successfully!
225+
Tweet ID: 9876543210987654321
226+
URL: https://twitter.com/i/status/9876543210987654321
227+
Reply to: 1234567890123456789
228+
Round: 3
229+
Target time: 2025-04-01 | 16:30:57 | EST
230+
```
231+
232+
**Features:**
233+
- **Image attachment**: Posts target frame images as replies to commitment tweets
234+
- **Automatic formatting**: Generates proper tweet text with #revealsopen hashtag
235+
- **Time calculation**: Automatically calculates target time from hours parameter
236+
- **Error handling**: Comprehensive error messages for API and file issues
237+
- **Configurable**: Support for different Twitter accounts via config files
238+
206239
### Score Calculation
207240

208241
Calculate similarity scores and rankings for a round:

src/actions.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
//! Actions module containing all CLI subcommand implementations
22
33
pub mod generate_commitment;
4-
pub mod collect_commitments;
4+
pub mod collect_commitments;
5+
pub mod post_target_frame;

src/actions/post_target_frame.rs

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
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

Comments
 (0)