use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use insta_note_clock::{ format_note_time, AppConfig, InstagramClient, LoginResult, SessionData, }; use tracing::{error, info, warn}; #[derive(Parser, Debug)] #[command( name = "insta-note-clock", author = "oiupoyt", version = "0.1.0", about = "Resilient CLI tool to display current time in an Instagram Note at safe intervals" )] struct Cli { /// Path to TOML configuration file #[arg(short, long, global = true)] config: Option, /// Dry-run mode: simulate requests and log payloads without sending to Instagram #[arg(long, global = true)] dry_run: bool, /// Time display mode override ("12h" or "24h") #[arg(short, long, global = true)] mode: Option, #[command(subcommand)] command: Commands, } #[derive(Subcommand, Debug)] enum Commands { /// Continuously update Instagram Note at configured interval with graceful shutdown Run, /// Post a single Note update and exit immediately Once, /// Authenticate with Instagram and save session to disk Login { /// Optional: directly import sessionid cookie rather than password login #[arg(long)] session_id: Option, /// Optional: user's ds_user_id numeric account ID #[arg(long)] user_id: Option, }, } #[tokio::main] async fn main() -> Result<()> { // Initialize tracing subscriber with RUST_LOG support (default info) tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .with_target(false) .init(); let cli = Cli::parse(); let mut config = AppConfig::load(cli.config.as_deref()) .context("Failed to initialize configuration")?; if let Some(ref m) = cli.mode { config.note.mode = m.clone(); } match cli.command { Commands::Login { session_id, user_id } => { handle_login(&config, session_id, user_id).await?; } Commands::Once => { let client = init_client(&config, cli.dry_run).await?; update_clock_note(&client, &config).await?; } Commands::Run => { let client = init_client(&config, cli.dry_run).await?; run_clock_loop(&client, &config).await?; } } Ok(()) } /// Initialize Instagram client by loading persisted session or config session tokens async fn init_client(config: &AppConfig, dry_run: bool) -> Result { let session_path = Path::new(&config.instagram.session_file); let session = if session_path.exists() { info!("Loading saved session from {}", session_path.display()); match SessionData::load_from_file(session_path) { Ok(s) if s.is_valid() => Some(s), Ok(_) => { warn!("Session in {} is missing required tokens.", session_path.display()); None } Err(e) => { warn!("Could not read session file: {}. Re-authentication required.", e); None } } } else { None }; // Fallback to config session_id if file not found let session = session.or_else(|| { if let (Some(sid), Some(uid)) = (&config.instagram.session_id, &config.instagram.ds_user_id) { if !sid.is_empty() && !uid.is_empty() { info!("Using session credentials from configuration"); let s = SessionData::new(sid.clone(), uid.clone(), "missing".to_string(), Some(config.instagram.username.clone())); let _ = s.save_to_file(session_path); return Some(s); } } None }); let session = if session.is_none() && dry_run { info!("[DRY RUN] No saved session found; using simulated dry-run session."); Some(SessionData::new( "dry_run_session_id".to_string(), "123456789".to_string(), "dry_run_csrf".to_string(), Some("dry_run_user".to_string()), )) } else { session }; if session.is_none() { error!( "No active session found!\n\ Please run `insta-note-clock login` to authenticate first,\n\ or set session_id and ds_user_id in your config.toml / environment variables." ); anyhow::bail!("Authentication required. Run `insta-note-clock login`."); } Ok(InstagramClient::new( None, session, config.note.max_requests_per_hour, dry_run, )) } /// Prompt input helper fn read_line(prompt: &str) -> Result { print!("{}", prompt); io::stdout().flush().context("Failed to flush stdout")?; let mut buffer = String::new(); io::stdin().read_line(&mut buffer).context("Failed to read line")?; Ok(buffer.trim().to_string()) } /// Interactive or manual login handler async fn handle_login( config: &AppConfig, cli_session_id: Option, cli_user_id: Option, ) -> Result<()> { let session_path = Path::new(&config.instagram.session_file); // Option A: Direct session cookie import let session_id = cli_session_id.or_else(|| config.instagram.session_id.clone()); let user_id = cli_user_id.or_else(|| config.instagram.ds_user_id.clone()); if let (Some(sid), Some(uid)) = (session_id, user_id) { if !sid.is_empty() && !uid.is_empty() { info!("Importing session ID and user ID directly..."); let session = SessionData::new( sid, uid, "token".to_string(), if config.instagram.username.is_empty() { None } else { Some(config.instagram.username.clone()) }, ); session.save_to_file(session_path)?; info!("Direct session successfully saved to {}", session_path.display()); return Ok(()); } } // Option B: Standard username/password login println!("\n=== Instagram Authentication ==="); println!("Choose login method:"); println!(" 1) Username and Password (with 2FA support)"); println!(" 2) Direct Session Cookie import (recommended to bypass bot challenges)"); print!("Select [1/2] (default 1): "); io::stdout().flush()?; let mut choice = String::new(); io::stdin().read_line(&mut choice)?; let choice = choice.trim(); if choice == "2" { println!("\n--- Tutorial: How to get your ds_user_id and sessionid cookies ---"); println!("1. Open your browser (Chrome, Firefox, Edge, or Brave)."); println!("2. Go to https://www.instagram.com and log in to your account."); println!("3. Press F12 (or right-click the page and select 'Inspect') to open Developer Tools."); println!("4. In the top tabs, select 'Application' (in Firefox, select 'Storage')."); println!("5. In the left panel, expand 'Cookies' and click 'https://www.instagram.com'."); println!("6. In the cookie list, locate:"); println!(" - 'ds_user_id' : Your numeric account ID (e.g., 6891234567)"); println!(" - 'sessionid' : Your authentication token string"); println!(" - 'csrftoken' : Optional CSRF token"); println!("7. Double-click the 'Value' column to copy each, then enter below:\n"); let sid = read_line("Enter 'sessionid' cookie value: ")?; let uid = read_line("Enter 'ds_user_id' cookie value: ")?; let csrf = read_line("Enter 'csrftoken' cookie value (optional, press Enter to skip): ")?; let csrf = if csrf.is_empty() { "token".to_string() } else { csrf }; let session = SessionData::new(sid, uid, csrf, None); session.save_to_file(session_path)?; info!("Session saved successfully to {}", session_path.display()); return Ok(()); } // Username / Password path let username = if !config.instagram.username.is_empty() { config.instagram.username.clone() } else { read_line("Instagram Username: ")? }; let password = if !config.instagram.password.is_empty() { config.instagram.password.clone() } else { print!("Instagram Password: "); io::stdout().flush()?; rpassword::read_password().context("Failed to read password securely")? }; let client = InstagramClient::new(None, None, config.note.max_requests_per_hour, false); info!("Attempting login for @{}...", username); match client.login(&username, &password).await? { LoginResult::Success(session) => { session.save_to_file(session_path)?; info!("Successfully logged in as @{}!", username); } LoginResult::TwoFactorRequired { info: two_factor, temp_session } => { println!("\nTwo-Factor Authentication (2FA) is enabled for this account."); if let Some(phone) = two_factor.obfuscated_phone_number { println!("Security code sent to phone ending in {}", phone); } let code = read_line("Enter the 6-digit 2FA verification code: ")?; let session = client.finish_two_factor_login( &username, &code, &two_factor.two_factor_identifier, temp_session, ).await?; session.save_to_file(session_path)?; info!("2FA verified successfully! Logged in as @{}.", username); } } Ok(()) } /// Execute a single note update async fn update_clock_note(client: &InstagramClient, config: &AppConfig) -> Result<()> { let tz = config.parse_timezone()?; let now = chrono::Utc::now(); let time_format = config.note.resolved_time_format(); let note_text = format_note_time( now, tz, time_format, &config.note.prefix, &config.note.suffix, ); let resp = client.create_note(¬e_text).await?; info!( "Note active! Text: \"{}\" (Note ID: {})", resp.text.unwrap_or(note_text), resp.id.unwrap_or_else(|| "N/A".to_string()) ); Ok(()) } /// Continuous clock update loop with graceful Ctrl+C shutdown async fn run_clock_loop(client: &InstagramClient, config: &AppConfig) -> Result<()> { let tz = config.parse_timezone()?; let interval = Duration::from_secs(config.note.interval_seconds); let time_format = config.note.resolved_time_format(); info!("=================================================="); info!("Instagram Note Clock Started"); info!("Timezone: {}", config.instagram.timezone); info!("Refresh Interval: {} seconds", config.note.interval_seconds); info!("Mode: {} (Format: \"{}{}{}\")", config.note.mode, config.note.prefix, time_format, config.note.suffix); info!("Max Requests / Hour: {}", config.note.max_requests_per_hour); if client.dry_run { info!("MODE: DRY-RUN (No requests will be sent to Instagram)"); } info!("Press Ctrl+C to stop cleanly."); info!("=================================================="); // Initial run immediately if let Err(e) = update_clock_note(client, config).await { error!("Error updating note: {:#}", e); } loop { tokio::select! { _ = tokio::time::sleep(interval) => { let now = chrono::Utc::now(); let time_format = config.note.resolved_time_format(); let note_text = format_note_time( now, tz, time_format, &config.note.prefix, &config.note.suffix, ); if let Err(e) = client.create_note(¬e_text).await { error!("Failed to update note: {:#}", e); } } _ = tokio::signal::ctrl_c() => { info!("\nReceived Ctrl+C. Shutting down insta-note-clock gracefully. Goodbye!"); break; } } } Ok(()) }