insta-note-clock/src/main.rs

341 lines
12 KiB
Rust

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<PathBuf>,
/// 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<String>,
#[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<String>,
/// Optional: user's ds_user_id numeric account ID
#[arg(long)]
user_id: Option<String>,
},
}
#[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<InstagramClient> {
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<String> {
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<String>,
cli_user_id: Option<String>,
) -> 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(&note_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(&note_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(())
}