341 lines
12 KiB
Rust
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(¬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(())
|
|
}
|