From c23d1281861282cec4b0246ac470dff4edc2bf7c Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 26 Sep 2026 20:41:21 +0530 Subject: [PATCH] Initial commit: insta-note-clock implementation with tests --- .env.example | 35 +++ .gitignore | 5 + Cargo.toml | 31 +++ README.md | 194 +++++++++++++++++ config.example.toml | 41 ++++ config.toml | 41 ++++ src/client.rs | 520 ++++++++++++++++++++++++++++++++++++++++++++ src/clock.rs | 80 +++++++ src/config.rs | 232 ++++++++++++++++++++ src/lib.rs | 9 + src/main.rs | 323 +++++++++++++++++++++++++++ src/session.rs | 85 ++++++++ tests/mock_test.rs | 122 +++++++++++ 13 files changed, 1718 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 config.example.toml create mode 100644 config.toml create mode 100644 src/client.rs create mode 100644 src/clock.rs create mode 100644 src/config.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/session.rs create mode 100644 tests/mock_test.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c980abf --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ============================================================================== +# insta-note-clock Environment Variables +# These override values in config.toml if set. +# ============================================================================== + +# Instagram credentials (Optional if authenticating via `insta-note-clock login`) +INSTA_INSTAGRAM__USERNAME=your_instagram_username +INSTA_INSTAGRAM__PASSWORD=your_instagram_password + +# Direct session cookie authentication (Alternative to username/password) +# Found in browser DevTools: Cookies -> instagram.com -> sessionid / ds_user_id +# INSTA_INSTAGRAM__SESSION_ID=your_sessionid_cookie +# INSTA_INSTAGRAM__DS_USER_ID=your_numeric_user_id + +# Path to session storage file +INSTA_INSTAGRAM__SESSION_FILE=./session.json + +# Timezone (valid IANA timezone string: "Asia/Kolkata", "America/New_York", "UTC", etc.) +INSTA_INSTAGRAM__TIMEZONE=Asia/Kolkata + +# Note update interval in seconds (SAFETY: Minimum 120s recommended to prevent bans) +INSTA_NOTE__INTERVAL_SECONDS=120 + +# Time format (strftime syntax, e.g. "%H:%M" for 24h, "%I:%M %p" for 12h) +INSTA_NOTE__TIME_FORMAT=%H:%M + +# Note Prefix and Suffix (Max note length on Instagram is 60 characters) +INSTA_NOTE__PREFIX="๐Ÿ•’ " +INSTA_NOTE__SUFFIX="" + +# Rate limiting cap (safety maximum per hour) +INSTA_NOTE__MAX_REQUESTS_PER_HOUR=30 + +# Logging level (trace, debug, info, warn, error) +RUST_LOG=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d60cd1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +**/*.rs.bk +Cargo.lock +.env +session.json diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8145373 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "insta-note-clock" +version = "0.1.0" +edition = "2021" +authors = ["oiupoyt"] +description = "A resilient CLI tool to update Instagram Notes with the current time at a safe, configurable interval" +license = "MIT" + +[dependencies] +tokio = { version = "1.40", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "rustls-tls"] } +chrono = { version = "0.4.38", features = ["serde"] } +chrono-tz = { version = "0.10.0", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +clap = { version = "4.5", features = ["derive", "env"] } +config = "0.14" +dotenvy = "0.15" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1.0" +thiserror = "2.0" +rand = "0.8" +uuid = { version = "1.10", features = ["v4"] } +hex = "0.4" +hmac = "0.12" +sha2 = "0.10" +rpassword = "7.3" + +[dev-dependencies] +wiremock = "0.6" diff --git a/README.md b/README.md new file mode 100644 index 0000000..57f074f --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# ๐Ÿ•’ insta-note-clock + +> **A high-resilience Rust CLI daemon that synchronizes your Instagram Note with a live digital clock at a safe, configurable interval.** + +--- + +### โš ๏ธ IMPORTANT LEGAL & TERMS OF SERVICE WARNING + +> [!CAUTION] +> **USE AT YOUR OWN RISK.** +> +> 1. Instagram / Meta has **no public API** for Notes. This tool interacts directly with Instagram's private Android mobile application endpoints. +> 2. Automating actions on Instagram directly violates **Section 3.2 of Instagram's Terms of Use** (*"You can't attempt to create accounts or access or collect information in unauthorized ways"* and *"You must not access Instagram's private API by means other than the Instagram app or website"*). +> 3. Instagram utilizes aggressive automated fraud detection and rate limiting. Frequent Note mutations (e.g., updating every 60 seconds) will almost certainly trigger HTTP 429 errors, action blocks, checkpoint challenges, or **permanent account suspension / ban**. +> 4. This tool enforces a **minimum safe default of 120 seconds**, a **rolling 30 requests/hour cap**, and **exponential backoff + jitter**. **Never use your primary personal account if you cannot afford to lose it.** Test with a secondary or disposable account first. + +--- + +## ๐Ÿ—๏ธ Architecture & Reverse Engineered Endpoints + +Existing open-source Rust crates were evaluated: +- `insta-rs`, `qinstagram`: Outdated and predate Instagram Notes (introduced in 2022/2023). +- `goofy`, `rocketapi`: Unofficial wrapper services or SaaS requiring third-party API keys and recurring subscriptions. + +Consequently, `insta-note-clock` implements a **native Rust HTTP engine using `reqwest`**, reverse-engineered directly from Instagram's Android client and referenced from the Python `instagrapi` library (`instagrapi.mixins.note`). + +### Endpoint Specification + +| Action | HTTP Method & Path | Form Payload | Headers / Cookies | +| :--- | :--- | :--- | :--- | +| **Create/Update Note** | `POST https://i.instagram.com/api/v1/notes/create_note` | `signed_body=SIGNATURE.{"note_style":0,"text":"...","_uuid":"...","audience":0}` | `sessionid`, `ds_user_id`, `csrftoken`, `X-IG-App-ID: 936619743392459`, `User-Agent: Android` | +| **Delete Note** | `POST https://i.instagram.com/api/v1/notes/delete_note/` | `signed_body=SIGNATURE.{"id":"","_uuid":"..."}` | Same as above | +| **Get Notes Tray** | `POST https://i.instagram.com/api/v1/notes/get_notes` | `signed_body=SIGNATURE.{"_uuid":"..."}` | Same as above | +| **Account Login** | `POST https://i.instagram.com/api/v1/accounts/login/` | `signed_body=SIGNATURE.{"username":"...","enc_password":"#PWD_INSTAGRAM:0::",...}` | Mobile device headers | +| **2FA Verification** | `POST https://i.instagram.com/api/v1/accounts/two_factor_login/` | `signed_body=SIGNATURE.{"username":"...","verification_code":"...","two_factor_identifier":"...",...}` | Mobile device headers | + +> **Note on Atomic Replacement:** Instagram enforces a strict limit of **one active Note per account**. Calling `create_note` automatically replaces any prior note on Instagram's server side without needing an explicit deletion step. + +--- + +## ๐Ÿ›ก๏ธ Safety & Rate Limiting Mechanics + +- **Default Interval:** 120 seconds (warns visibly if configured lower). +- **Hourly Budget Cap:** Limits mutations to a rolling maximum of 30 requests/hour by default. +- **HTTP 429 Backoff:** On receiving rate limit code 429, the daemon pauses for an exponential backoff starting at **5 minutes (300 seconds)** + random jitter (5โ€“30s), retrying up to 3 times before halting. +- **Immediate Fatal Exit on 401/403:** The daemon never loops or retries authentication failures, preventing credential lockout. +- **Dry Run Mode:** Test payloads, formatting, and time calculations without making any network requests. + +--- + +## ๐Ÿ“ฆ Project Structure + +``` +insta-note-clock/ +โ”œโ”€โ”€ Cargo.toml # Rust package & dependency definitions +โ”œโ”€โ”€ .env.example # Template for environment variable overrides +โ”œโ”€โ”€ config.example.toml # Example TOML configuration file +โ”œโ”€โ”€ config.toml # Active configuration file +โ”œโ”€โ”€ .gitignore # Prevents secrets or session.json from leaking +โ”œโ”€โ”€ README.md # Comprehensive documentation +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ lib.rs # Library root re-exporting core modules +โ”‚ โ”œโ”€โ”€ main.rs # CLI entrypoint, argument parsing & daemon loop +โ”‚ โ”œโ”€โ”€ client.rs # Instagram private API client, signing & backoff +โ”‚ โ”œโ”€โ”€ clock.rs # Timezone parsing & note formatting logic +โ”‚ โ”œโ”€โ”€ config.rs # Configuration loading & validation logic +โ”‚ โ””โ”€โ”€ session.rs # Session persistence & secure disk storage +โ””โ”€โ”€ tests/ + โ””โ”€โ”€ mock_test.rs # WireMock integration tests (zero live API calls) +``` + +--- + +## ๐Ÿš€ Installation & Building + +### Prerequisites +- Rust stable (1.75+) with `cargo` + +```bash +git clone insta-note-clock +cd insta-note-clock +cargo build --release +``` +The compiled binary will be located at `target/release/insta-note-clock`. + +--- + +## โš™๏ธ Configuration + +You can configure `insta-note-clock` via `config.toml` or environment variables (prefixed with `INSTA_`). + +### `config.toml` + +```toml +[instagram] +username = "your_username" +password = "" # Optional: leave blank for interactive login or session cookies +session_file = "./session.json" +timezone = "Asia/Kolkata" # Any valid IANA timezone (e.g., "America/New_York", "UTC") + +[note] +interval_seconds = 120 # Minimum recommended: 120 seconds +time_format = "%H:%M" # strftime format (%H:%M for 24h, %I:%M %p for 12h) +prefix = "๐Ÿ•’ " # Prefix placed before time +suffix = "" # Optional suffix (Note limit: 60 chars) +max_requests_per_hour = 30 # Rolling hourly safety cap +``` + +--- + +## ๐Ÿ”‘ Authentication Guide + +`insta-note-clock` supports two methods: + +### Method 1: Direct Session Cookie Import (Recommended โญ) +*This is the safest method because it bypasses Instagram's mobile automated login bot detection.* + +1. Log in to [instagram.com](https://instagram.com) in Chrome, Firefox, or Edge. +2. Press `F12` to open Developer Tools, go to **Application** (or **Storage**) -> **Cookies** -> `https://www.instagram.com`. +3. Locate the `sessionid` and `ds_user_id` cookies. +4. Run: + ```bash + insta-note-clock login --session-id "YOUR_SESSION_ID" --user-id "YOUR_DS_USER_ID" + ``` + Or launch `insta-note-clock login` and select Option `2`. + +### Method 2: Interactive Login (with 2FA) +Run: +```bash +insta-note-clock login +``` +Select option `1`. You will be prompted for your username, password (hidden input), and 2FA SMS / Authenticator verification code if enabled. + +Once authenticated, your session is securely saved to `session.json` with `0600` permissions and reused on subsequent runs. + +--- + +## ๐Ÿ’ป CLI Usage + +### 1. Test in Dry-Run Mode (Safe) +Simulate formatting and verify the request payload without hitting Instagram: +```bash +cargo run -- --dry-run once +``` + +### 2. Update Note Once +Post a single timestamp Note update and exit: +```bash +cargo run -- once +``` + +### 3. Run Daemon Loop +Start the clock synchronization daemon: +```bash +cargo run -- run +``` +To exit cleanly, press `Ctrl+C`. + +--- + +## ๐Ÿ“ฑ Running on Android Tablet Server (Termux) + +If deploying to your Android tablet server running Termux: + +1. Install prerequisites in Termux: + ```bash + pkg update + pkg install rust binutils openssl + ``` +2. Copy or clone the project into Termux: + ```bash + cd ~/insta-note-clock + cargo build --release + ``` +3. Authenticate: + ```bash + ./target/release/insta-note-clock login + ``` +4. Run under `tmux` or `nohup`: + ```bash + tmux new -s note-clock + ./target/release/insta-note-clock run + # Detach with Ctrl+B then D + ``` + +--- + +## ๐Ÿงช Testing + +Run unit tests and mocked integration tests: +```bash +cargo test +``` +All integration tests utilize `wiremock` to simulate Instagram's API responses locally without ever sending external network packets. diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..acf4010 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,41 @@ +# ============================================================================== +# insta-note-clock Configuration +# ============================================================================== + +[instagram] +# Your Instagram username +username = "" + +# Your Instagram password (leave empty if logging in interactively or via session cookie) +password = "" + +# Direct session token fallback (recommended to bypass automated bot detection) +# session_id = "" +# ds_user_id = "" + +# Persistent session storage file path +session_file = "./session.json" + +# IANA Timezone identifier (e.g., "Asia/Kolkata", "America/New_York", "Europe/London", "UTC") +timezone = "Asia/Kolkata" + +[note] +# Update interval in seconds. +# WARNING: Instagram aggressively rate-limits Note mutations. +# Setting this below 120s carries extreme risk of temporary or permanent account suspension. +interval_seconds = 120 + +# Time formatting using standard chrono strftime specifiers: +# "%H:%M" -> "14:30" (24-hour) +# "%I:%M %p" -> "02:30 PM" (12-hour) +# "%a %H:%M" -> "Sat 14:30" +time_format = "%H:%M" + +# Prefix placed before the formatted time +prefix = "๐Ÿ•’ " + +# Suffix placed after the formatted time +suffix = "" + +# Hard safety cap on total API requests allowed in any rolling 60-minute window +max_requests_per_hour = 30 diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..acf4010 --- /dev/null +++ b/config.toml @@ -0,0 +1,41 @@ +# ============================================================================== +# insta-note-clock Configuration +# ============================================================================== + +[instagram] +# Your Instagram username +username = "" + +# Your Instagram password (leave empty if logging in interactively or via session cookie) +password = "" + +# Direct session token fallback (recommended to bypass automated bot detection) +# session_id = "" +# ds_user_id = "" + +# Persistent session storage file path +session_file = "./session.json" + +# IANA Timezone identifier (e.g., "Asia/Kolkata", "America/New_York", "Europe/London", "UTC") +timezone = "Asia/Kolkata" + +[note] +# Update interval in seconds. +# WARNING: Instagram aggressively rate-limits Note mutations. +# Setting this below 120s carries extreme risk of temporary or permanent account suspension. +interval_seconds = 120 + +# Time formatting using standard chrono strftime specifiers: +# "%H:%M" -> "14:30" (24-hour) +# "%I:%M %p" -> "02:30 PM" (12-hour) +# "%a %H:%M" -> "Sat 14:30" +time_format = "%H:%M" + +# Prefix placed before the formatted time +prefix = "๐Ÿ•’ " + +# Suffix placed after the formatted time +suffix = "" + +# Hard safety cap on total API requests allowed in any rolling 60-minute window +max_requests_per_hour = 30 diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..84d55f6 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,520 @@ +use crate::session::SessionData; +use anyhow::{anyhow, Context, Result}; +use hmac::{Hmac, Mac}; +use rand::Rng; +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT_LANGUAGE, COOKIE, USER_AGENT}; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::Sha256; +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; +use tracing::{debug, error, info, warn}; + +type HmacSha256 = Hmac; + +/// Instagram Private Android App HMAC-SHA256 signature key (extracted from official APK) +/// Used by Android Instagram clients to sign API POST bodies. +pub const IG_SIG_KEY: &str = "25e295153a2661b42f877172ffacb3e78a5b38708c281457fb4404501a3f4979"; + +/// Default Instagram Android User Agent matching a real Pixel 8 Pro device +pub const DEFAULT_USER_AGENT: &str = + "Instagram 448.0.0.0.20 Android (34/14; 480dpi; 1344x2992; Google/google; Pixel 8 Pro; husky; husky; en_US; 1065560286)"; + +pub const IG_APP_ID: &str = "936619743392459"; + +/// Rate limiting tracker to prevent exceeding max requests per hour +#[derive(Debug)] +pub struct RateLimiter { + max_per_hour: u32, + request_timestamps: VecDeque, +} + +impl RateLimiter { + pub fn new(max_per_hour: u32) -> Self { + Self { + max_per_hour, + request_timestamps: VecDeque::new(), + } + } + + /// Wait if current request count in the last hour exceeds `max_per_hour` + pub async fn acquire(&mut self) { + let one_hour = Duration::from_secs(3600); + loop { + let now = Instant::now(); + while let Some(&t) = self.request_timestamps.front() { + if now.duration_since(t) > one_hour { + self.request_timestamps.pop_front(); + } else { + break; + } + } + + if (self.request_timestamps.len() as u32) < self.max_per_hour { + self.request_timestamps.push_back(now); + break; + } + + if let Some(&oldest) = self.request_timestamps.front() { + let wait_time = one_hour.saturating_sub(now.duration_since(oldest)); + warn!( + "โณ Hourly rate limit reached ({}/{} requests in past hour). Pausing for {} seconds...", + self.request_timestamps.len(), + self.max_per_hour, + wait_time.as_secs() + ); + tokio::time::sleep(wait_time + Duration::from_secs(1)).await; + } + } + } +} + +/// Details of a created Instagram Note +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteResponse { + pub id: Option, + pub text: Option, + pub created_at: Option, + pub status: String, +} + +/// Two-Factor authentication challenge response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TwoFactorInfo { + pub two_factor_identifier: String, + pub phone_verification_settings: Option, + pub obfuscated_phone_number: Option, +} + +pub enum LoginResult { + Success(SessionData), + TwoFactorRequired { + info: TwoFactorInfo, + temp_session: SessionData, + }, +} + +#[derive(Clone)] +pub struct InstagramClient { + client: Client, + pub base_url: String, + pub session: Arc>>, + pub rate_limiter: Arc>, + pub dry_run: bool, +} + +impl InstagramClient { + pub fn new(base_url: Option, session: Option, max_requests_per_hour: u32, dry_run: bool) -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("Failed to build HTTP client"); + + Self { + client, + base_url: base_url.unwrap_or_else(|| "https://i.instagram.com/api/v1".to_string()), + session: Arc::new(Mutex::new(session)), + rate_limiter: Arc::new(Mutex::new(RateLimiter::new(max_requests_per_hour))), + dry_run, + } + } + + /// Sign a JSON string using the Instagram HMAC-SHA256 signature key. + /// + /// Instagram expects form body `signed_body=SIGNATURE.`. + pub fn sign_payload(json_str: &str) -> String { + let mut mac = HmacSha256::new_from_slice(IG_SIG_KEY.as_bytes()) + .expect("HMAC can take key of any size"); + mac.update(json_str.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + format!("{}.{}", signature, json_str) + } + + /// Helper to construct default Instagram Android App headers + fn build_headers(&self, session: &SessionData) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT)); + headers.insert("X-IG-App-ID", HeaderValue::from_static(IG_APP_ID)); + headers.insert( + "X-IG-Device-ID", + HeaderValue::from_str(&session.client_uuid).unwrap_or(HeaderValue::from_static("")), + ); + headers.insert( + "X-IG-Android-ID", + HeaderValue::from_str(&session.device_id).unwrap_or(HeaderValue::from_static("")), + ); + headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US, en;q=0.9")); + + // Format session cookies + let cookie_str = format!( + "sessionid={}; ds_user_id={}; csrftoken={};", + session.session_id, session.ds_user_id, session.csrf_token + ); + headers.insert( + COOKIE, + HeaderValue::from_str(&cookie_str).unwrap_or(HeaderValue::from_static("")), + ); + + headers + } + + /// Authenticate using direct username and password + pub async fn login(&self, username: &str, password: &str) -> Result { + let login_url = format!("{}/accounts/login/", self.base_url); + let temp_session = SessionData::new( + String::new(), + String::new(), + "missing".to_string(), + Some(username.to_string()), + ); + + let timestamp = chrono::Utc::now().timestamp(); + // Instagram password encryption format `#PWD_INSTAGRAM:0::` + let formatted_pwd = format!("#PWD_INSTAGRAM:0:{}:{}", timestamp, password); + + let payload = json!({ + "username": username, + "enc_password": formatted_pwd, + "device_id": temp_session.device_id, + "google_tokens": "[]", + "login_attempt_count": "0", + "phone_id": temp_session.phone_id, + "_uuid": temp_session.client_uuid + }); + + let signed = Self::sign_payload(&payload.to_string()); + + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT)); + headers.insert("X-IG-App-ID", HeaderValue::from_static(IG_APP_ID)); + headers.insert("X-IG-Device-ID", HeaderValue::from_str(&temp_session.client_uuid)?); + headers.insert("X-IG-Android-ID", HeaderValue::from_str(&temp_session.device_id)?); + + debug!("Sending login request to {}", login_url); + let resp = self + .client + .post(&login_url) + .headers(headers) + .form(&[("signed_body", signed)]) + .send() + .await + .context("Failed to send login request")?; + + let session_id = resp + .cookies() + .find(|c| c.name() == "sessionid") + .map(|c| c.value().to_string()) + .unwrap_or_default(); + + let csrf_token = resp + .cookies() + .find(|c| c.name() == "csrftoken") + .map(|c| c.value().to_string()) + .unwrap_or_else(|| "missing".to_string()); + + let status = resp.status(); + let body: Value = resp.json().await.context("Failed to parse login response")?; + + if status == StatusCode::OK && body.get("logged_in_user").is_some() { + let user = &body["logged_in_user"]; + let ds_user_id = user["pk"].as_i64().map(|p| p.to_string()) + .or_else(|| user["pk"].as_str().map(|s| s.to_string())) + .unwrap_or_default(); + + let mut final_session = temp_session; + final_session.session_id = session_id; + final_session.ds_user_id = ds_user_id; + final_session.csrf_token = csrf_token; + + *self.session.lock().await = Some(final_session.clone()); + return Ok(LoginResult::Success(final_session)); + } + + // Check if 2FA is required + if body.get("two_factor_required").and_then(|v| v.as_bool()).unwrap_or(false) { + let two_factor_info = &body["two_factor_info"]; + let identifier = two_factor_info["two_factor_identifier"] + .as_str() + .ok_or_else(|| anyhow!("Missing two_factor_identifier in response"))? + .to_string(); + + return Ok(LoginResult::TwoFactorRequired { + info: TwoFactorInfo { + two_factor_identifier: identifier, + phone_verification_settings: two_factor_info.get("phone_verification_settings").cloned(), + obfuscated_phone_number: two_factor_info.get("obfuscated_phone_number").and_then(|v| v.as_str()).map(|s| s.to_string()), + }, + temp_session, + }); + } + + let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown login error"); + let error_type = body.get("error_type").and_then(|e| e.as_str()).unwrap_or(""); + Err(anyhow!("Instagram login failed: {} (type: {})", msg, error_type)) + } + + /// Complete 2FA login with SMS/Authenticator code + pub async fn finish_two_factor_login( + &self, + username: &str, + verification_code: &str, + identifier: &str, + mut session: SessionData, + ) -> Result { + let two_factor_url = format!("{}/accounts/two_factor_login/", self.base_url); + let payload = json!({ + "verification_code": verification_code.trim(), + "two_factor_identifier": identifier, + "username": username, + "device_id": session.device_id, + "phone_id": session.phone_id, + "_uuid": session.client_uuid, + "verification_method": "1" + }); + + let signed = Self::sign_payload(&payload.to_string()); + + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT)); + headers.insert("X-IG-App-ID", HeaderValue::from_static(IG_APP_ID)); + + let resp = self + .client + .post(&two_factor_url) + .headers(headers) + .form(&[("signed_body", signed)]) + .send() + .await + .context("Failed to send 2FA verification request")?; + + let session_id = resp + .cookies() + .find(|c| c.name() == "sessionid") + .map(|c| c.value().to_string()) + .unwrap_or_default(); + + let csrf_token = resp + .cookies() + .find(|c| c.name() == "csrftoken") + .map(|c| c.value().to_string()) + .unwrap_or_else(|| "missing".to_string()); + + let status = resp.status(); + let body: Value = resp.json().await.context("Failed to parse 2FA response")?; + + if status == StatusCode::OK && body.get("logged_in_user").is_some() { + let user = &body["logged_in_user"]; + let ds_user_id = user["pk"].as_i64().map(|p| p.to_string()) + .or_else(|| user["pk"].as_str().map(|s| s.to_string())) + .unwrap_or_default(); + + session.session_id = session_id; + session.ds_user_id = ds_user_id; + session.csrf_token = csrf_token; + + *self.session.lock().await = Some(session.clone()); + return Ok(session); + } + + let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("Invalid 2FA code"); + Err(anyhow!("Instagram 2FA verification failed: {}", msg)) + } + + /// Execute a private API POST request with exponential backoff on HTTP 429 + /// and immediate fatal rejection on 401/403. + async fn execute_signed_request( + &self, + endpoint: &str, + payload: Value, + ) -> Result { + let session = { + let guard = self.session.lock().await; + guard.clone().ok_or_else(|| anyhow!("No active session. Please authenticate first."))? + }; + + let url = format!("{}/{}", self.base_url.trim_end_matches('/'), endpoint.trim_start_matches('/')); + let json_payload = payload.to_string(); + let signed_payload = Self::sign_payload(&json_payload); + + if self.dry_run { + info!("๐Ÿ”ฌ [DRY RUN] Request to: {}", url); + info!("๐Ÿ”ฌ [DRY RUN] Headers: User-Agent, X-IG-App-ID, Cookie: sessionid=***, ds_user_id={}", session.ds_user_id); + info!("๐Ÿ”ฌ [DRY RUN] Payload: {}", json_payload); + info!("๐Ÿ”ฌ [DRY RUN] Signed Form: signed_body={}", signed_payload); + return Ok(json!({ + "status": "ok", + "id": "dry-run-note-id", + "created_at": chrono::Utc::now().timestamp(), + "text": payload.get("text").cloned().unwrap_or(Value::Null) + })); + } + + let max_retries = 3; + let mut retry_count = 0; + let mut base_delay_secs: u64 = 300; // 5 minutes initial backoff on 429 + + loop { + // Check rate limiter before sending + { + let mut rl = self.rate_limiter.lock().await; + rl.acquire().await; + } + + let headers = self.build_headers(&session); + debug!("Sending POST to {} (attempt {})", url, retry_count + 1); + + let res = self + .client + .post(&url) + .headers(headers) + .form(&[("signed_body", &signed_payload)]) + .send() + .await; + + match res { + Ok(response) => { + let status = response.status(); + debug!("Received response with status {}", status); + + // 1. Success + if status.is_success() { + let parsed: Value = response.json().await.context("Failed to parse JSON response")?; + return Ok(parsed); + } + + // 2. Fatal Auth Errors - Never retry! + if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { + error!("๐Ÿšจ Authentication failure (HTTP {}). Instagram rejected session credentials.", status); + let err_text = response.text().await.unwrap_or_default(); + return Err(anyhow!("Authentication failed (HTTP {}): {}. Please re-login.", status, err_text)); + } + + // 3. HTTP 429 Rate Limited + if status == StatusCode::TOO_MANY_REQUESTS { + if retry_count >= max_retries { + return Err(anyhow!( + "HTTP 429 Too Many Requests: Rate limit exceeded after {} retries. Halting for safety.", + max_retries + )); + } + + let jitter = rand::thread_rng().gen_range(5..=30); + let wait_duration = Duration::from_secs(base_delay_secs + jitter); + + warn!( + "โš ๏ธ HTTP 429 (Too Many Requests) received from Instagram!\n\ + Entering exponential backoff: sleeping for {} seconds (attempt {}/{})...", + wait_duration.as_secs(), + retry_count + 1, + max_retries + ); + + tokio::time::sleep(wait_duration).await; + retry_count += 1; + base_delay_secs *= 2; + continue; + } + + // 4. Transient Server Errors (500, 502, 503, 504) + if status.is_server_error() { + if retry_count >= max_retries { + return Err(anyhow!("Server error {} returned after {} retries", status, max_retries)); + } + let wait_duration = Duration::from_secs(5 * (retry_count as u64 + 1)); + warn!("Server error {} received. Retrying in {} seconds...", status, wait_duration.as_secs()); + tokio::time::sleep(wait_duration).await; + retry_count += 1; + continue; + } + + // 5. Other Client Errors + let err_body = response.text().await.unwrap_or_default(); + return Err(anyhow!("Instagram API error (HTTP {}): {}", status, err_body)); + } + Err(err) => { + if retry_count >= max_retries { + return Err(anyhow!("Network request failed after {} retries: {}", max_retries, err)); + } + let wait_duration = Duration::from_secs(5 * (retry_count as u64 + 1)); + warn!("Network error: {}. Retrying in {} seconds...", err, wait_duration.as_secs()); + tokio::time::sleep(wait_duration).await; + retry_count += 1; + } + } + } + } + + /// Create or update an Instagram Note with the given text. + /// + /// Endpoint: `POST /api/v1/notes/create_note` + /// Payload: `{"note_style": 0, "text": text, "_uuid": client_uuid, "audience": 0}` + /// + /// Note: Instagram only allows ONE note per user at any time. Calling `create_note` + /// atomically replaces any previous note. + pub async fn create_note(&self, text: &str) -> Result { + let client_uuid = { + let guard = self.session.lock().await; + guard.as_ref().map(|s| s.client_uuid.clone()).unwrap_or_default() + }; + + let payload = json!({ + "note_style": 0, + "text": text, + "_uuid": client_uuid, + "audience": 0 + }); + + info!("Updating Instagram Note to: \"{}\"", text); + let resp = self.execute_signed_request("/notes/create_note", payload).await?; + + let note_resp: NoteResponse = serde_json::from_value(resp) + .context("Failed to parse NoteResponse from Instagram API response")?; + + Ok(note_resp) + } + + /// Delete the currently active note by its ID + /// + /// Endpoint: `POST /api/v1/notes/delete_note/` + /// Payload: `{"id": note_id, "_uuid": client_uuid}` + pub async fn delete_note(&self, note_id: &str) -> Result<()> { + let client_uuid = { + let guard = self.session.lock().await; + guard.as_ref().map(|s| s.client_uuid.clone()).unwrap_or_default() + }; + + let payload = json!({ + "id": note_id, + "_uuid": client_uuid + }); + + debug!("Deleting Instagram Note with ID {}", note_id); + let resp = self.execute_signed_request("/notes/delete_note/", payload).await?; + if resp.get("status").and_then(|s| s.as_str()) == Some("ok") { + info!("Successfully deleted Instagram Note (ID: {})", note_id); + Ok(()) + } else { + Err(anyhow!("Failed to delete note: {:?}", resp)) + } + } + + /// Fetch active notes in inbox + /// + /// Endpoint: `POST /api/v1/notes/get_notes` + /// Payload: `{"_uuid": client_uuid}` + pub async fn get_notes(&self) -> Result { + let client_uuid = { + let guard = self.session.lock().await; + guard.as_ref().map(|s| s.client_uuid.clone()).unwrap_or_default() + }; + + let payload = json!({ + "_uuid": client_uuid + }); + + self.execute_signed_request("/notes/get_notes", payload).await + } +} diff --git a/src/clock.rs b/src/clock.rs new file mode 100644 index 0000000..4f34c1c --- /dev/null +++ b/src/clock.rs @@ -0,0 +1,80 @@ +use chrono::{DateTime, Utc}; +use chrono_tz::Tz; +use std::str::FromStr; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ClockError { + #[error("Invalid timezone string '{0}': must be a valid IANA timezone like 'Asia/Kolkata' or 'UTC'")] + InvalidTimezone(String), +} + +/// Helper to parse an IANA timezone string into a `chrono_tz::Tz` +pub fn parse_timezone(tz_str: &str) -> Result { + Tz::from_str(tz_str).map_err(|_| ClockError::InvalidTimezone(tz_str.to_string())) +} + +/// Format the current time for the Instagram Note text with given timezone, format, and optional prefix/suffix. +/// +/// Instagram Notes have a maximum limit of 60 UTF-8 characters. If the resulting string exceeds 60 chars, +/// it will be cleanly truncated to prevent Instagram API validation errors. +pub fn format_note_time( + now: DateTime, + timezone: Tz, + time_format: &str, + prefix: &str, + suffix: &str, +) -> String { + let local_time = now.with_timezone(&timezone); + let formatted_time = local_time.format(time_format).to_string(); + let full_text = format!("{}{}{}", prefix, formatted_time, suffix); + + // Enforce Instagram's 60-character limit + if full_text.chars().count() > 60 { + full_text.chars().take(60).collect() + } else { + full_text + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn test_valid_timezone_parsing() { + assert!(parse_timezone("Asia/Kolkata").is_ok()); + assert!(parse_timezone("UTC").is_ok()); + assert!(parse_timezone("America/New_York").is_ok()); + assert!(parse_timezone("Europe/London").is_ok()); + } + + #[test] + fn test_invalid_timezone_parsing() { + assert!(parse_timezone("Mars/Olympus").is_err()); + assert!(parse_timezone("").is_err()); + } + + #[test] + fn test_format_note_time_kolkata() { + let tz = parse_timezone("Asia/Kolkata").unwrap(); + // 2026-09-26 12:00:00 UTC -> 17:30:00 IST (+05:30) + let utc_time = Utc.with_ymd_and_hms(2026, 9, 26, 12, 0, 0).unwrap(); + + let formatted_24h = format_note_time(utc_time, tz, "%H:%M", "๐Ÿ•’ ", ""); + assert_eq!(formatted_24h, "๐Ÿ•’ 17:30"); + + let formatted_12h = format_note_time(utc_time, tz, "%I:%M %p", "๐Ÿ•’ ", " (IST)"); + assert_eq!(formatted_12h, "๐Ÿ•’ 05:30 PM (IST)"); + } + + #[test] + fn test_format_note_time_truncation() { + let tz = parse_timezone("UTC").unwrap(); + let utc_time = Utc.with_ymd_and_hms(2026, 9, 26, 12, 0, 0).unwrap(); + let long_prefix = "A".repeat(70); + let note = format_note_time(utc_time, tz, "%H:%M", &long_prefix, ""); + assert_eq!(note.chars().count(), 60); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8fd88e7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,232 @@ +use crate::clock::parse_timezone; +use anyhow::{Context, Result}; +use chrono_tz::Tz; +use config::{Config, Environment, File}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tracing::warn; + +pub const MIN_SAFE_INTERVAL_SECONDS: u64 = 120; +pub const DEFAULT_MAX_REQUESTS_PER_HOUR: u32 = 30; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstagramConfig { + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub ds_user_id: Option, + #[serde(default = "default_session_file")] + pub session_file: String, + #[serde(default = "default_timezone")] + pub timezone: String, +} + +impl Default for InstagramConfig { + fn default() -> Self { + Self { + username: String::new(), + password: String::new(), + session_id: None, + ds_user_id: None, + session_file: default_session_file(), + timezone: default_timezone(), + } + } +} + +fn default_session_file() -> String { + "./session.json".to_string() +} + +fn default_timezone() -> String { + "Asia/Kolkata".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteConfig { + #[serde(default = "default_interval")] + pub interval_seconds: u64, + #[serde(default = "default_time_format")] + pub time_format: String, + #[serde(default = "default_prefix")] + pub prefix: String, + #[serde(default)] + pub suffix: String, + #[serde(default = "default_max_requests_per_hour")] + pub max_requests_per_hour: u32, +} + +impl Default for NoteConfig { + fn default() -> Self { + Self { + interval_seconds: default_interval(), + time_format: default_time_format(), + prefix: default_prefix(), + suffix: String::new(), + max_requests_per_hour: default_max_requests_per_hour(), + } + } +} + +fn default_interval() -> u64 { + 120 +} + +fn default_time_format() -> String { + "%H:%M".to_string() +} + +fn default_prefix() -> String { + "๐Ÿ•’ ".to_string() +} + +fn default_max_requests_per_hour() -> u32 { + DEFAULT_MAX_REQUESTS_PER_HOUR +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AppConfig { + #[serde(default)] + pub instagram: InstagramConfig, + #[serde(default)] + pub note: NoteConfig, +} + +impl AppConfig { + /// Load configuration from an optional file path and environment variables (`INSTA_*`). + /// Also loads `.env` if available. + pub fn load(config_path: Option<&Path>) -> Result { + // Attempt to load .env file silently + let _ = dotenvy::dotenv(); + + let mut builder = Config::builder(); + + // 1. Defaults + builder = builder + .set_default("instagram.session_file", "./session.json")? + .set_default("instagram.timezone", "Asia/Kolkata")? + .set_default("note.interval_seconds", 120)? + .set_default("note.time_format", "%H:%M")? + .set_default("note.prefix", "๐Ÿ•’ ")? + .set_default("note.suffix", "")? + .set_default("note.max_requests_per_hour", 30)?; + + // 2. Load config file if specified, or check default paths + let paths_to_check = if let Some(p) = config_path { + vec![p.to_path_buf()] + } else { + vec![ + PathBuf::from("config.toml"), + PathBuf::from("config/config.toml"), + PathBuf::from("insta-note-clock.toml"), + ] + }; + + for p in paths_to_check { + if p.exists() { + builder = builder.add_source(File::from(p)); + break; + } + } + + // 3. Environment overrides (e.g., INSTA_INSTAGRAM__USERNAME or INSTA_NOTE__INTERVAL_SECONDS) + builder = builder.add_source( + Environment::with_prefix("INSTA") + .separator("__") + .try_parsing(true), + ); + + let config: AppConfig = builder + .build() + .context("Failed to build configuration")? + .try_deserialize() + .context("Failed to deserialize configuration")?; + + config.validate()?; + Ok(config) + } + + /// Perform safety checks and validation on configuration values + pub fn validate(&self) -> Result<()> { + if self.note.interval_seconds < MIN_SAFE_INTERVAL_SECONDS { + warn!( + "โš ๏ธ DANGER: Configured interval is {}s, which is below the minimum safe threshold ({}s)!\n\ + Instagram aggressively rate-limits Note updates. Using intervals below {}s will almost\n\ + certainly trigger HTTP 429 errors, automated bot detection, and temporary or permanent\n\ + account suspension.", + self.note.interval_seconds, + MIN_SAFE_INTERVAL_SECONDS, + MIN_SAFE_INTERVAL_SECONDS + ); + } + + // Validate timezone + let _ = self.parse_timezone()?; + + Ok(()) + } + + /// Get parsed chrono_tz::Tz + pub fn parse_timezone(&self) -> Result { + parse_timezone(&self.instagram.timezone).map_err(|e| anyhow::anyhow!(e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = AppConfig::default(); + assert_eq!(config.note.interval_seconds, 120); + assert_eq!(config.note.time_format, "%H:%M"); + assert_eq!(config.note.prefix, "๐Ÿ•’ "); + assert_eq!(config.note.max_requests_per_hour, 30); + assert_eq!(config.instagram.timezone, "Asia/Kolkata"); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_parse_toml_string() { + let toml_str = r#" + [instagram] + username = "testuser" + password = "testpassword" + timezone = "Europe/Paris" + + [note] + interval_seconds = 180 + time_format = "%I:%M %p" + prefix = "Current Time: " + max_requests_per_hour = 20 + "#; + + let cfg: AppConfig = Config::builder() + .add_source(File::from_str(toml_str, config::FileFormat::Toml)) + .build() + .unwrap() + .try_deserialize() + .unwrap(); + + assert_eq!(cfg.instagram.username, "testuser"); + assert_eq!(cfg.instagram.password, "testpassword"); + assert_eq!(cfg.instagram.timezone, "Europe/Paris"); + assert_eq!(cfg.note.interval_seconds, 180); + assert_eq!(cfg.note.time_format, "%I:%M %p"); + assert_eq!(cfg.note.prefix, "Current Time: "); + assert_eq!(cfg.note.max_requests_per_hour, 20); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn test_invalid_timezone_fails_validation() { + let mut config = AppConfig::default(); + config.instagram.timezone = "Invalid/Timezone".to_string(); + assert!(config.validate().is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7514d7d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod client; +pub mod clock; +pub mod config; +pub mod session; + +pub use client::{InstagramClient, LoginResult, NoteResponse}; +pub use clock::{format_note_time, parse_timezone}; +pub use config::{AppConfig, InstagramConfig, NoteConfig}; +pub use session::SessionData; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..212bb54 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,323 @@ +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, + + #[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 config = AppConfig::load(cli.config.as_deref()) + .context("Failed to initialize configuration")?; + + 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!("\nTo obtain your session cookie:"); + println!("1. Log in to instagram.com in any web browser."); + println!("2. Open DevTools (F12) -> Application/Storage -> Cookies -> instagram.com"); + println!("3. Copy the values of 'sessionid' and 'ds_user_id'.\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!("\n๐Ÿ” Two-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 note_text = format_note_time( + now, + tz, + &config.note.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); + + info!("=================================================="); + info!("๐Ÿ•’ Instagram Note Clock Started"); + info!("Timezone: {}", config.instagram.timezone); + info!("Refresh Interval: {} seconds", config.note.interval_seconds); + info!("Format: \"{}{}{}\"", config.note.prefix, config.note.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 note_text = format_note_time( + now, + tz, + &config.note.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!("\n๐Ÿ›‘ Received Ctrl+C. Shutting down insta-note-clock gracefully. Goodbye!"); + break; + } + } + } + + Ok(()) +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..aa1b1a4 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,85 @@ +use std::fs; +use std::path::Path; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tracing::info; +use uuid::Uuid; + +/// Persistent session state for Instagram private API. +/// +/// Storing session tokens and consistent device identifiers prevents +/// triggering suspicious activity and checkpoint challenges from Instagram. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionData { + /// Session cookie `sessionid` + pub session_id: String, + /// User's Instagram numeric account ID (`ds_user_id`) + pub ds_user_id: String, + /// CSRF token cookie (`csrftoken`) + pub csrf_token: String, + /// Consistent Android device ID (16 hex characters) + pub device_id: String, + /// Persistent client UUID + pub client_uuid: String, + /// Phone ID UUID + pub phone_id: String, + /// Username associated with this session + pub username: Option, +} + +impl SessionData { + /// Create a new session with freshly generated device IDs + pub fn new(session_id: String, ds_user_id: String, csrf_token: String, username: Option) -> Self { + let client_uuid = Uuid::new_v4().to_string(); + let phone_id = Uuid::new_v4().to_string(); + // Generate a 16-hex character device ID mimicking Android's Settings.Secure.ANDROID_ID + let device_id = format!("android-{}", &client_uuid.replace('-', "")[..16]); + + Self { + session_id, + ds_user_id, + csrf_token, + device_id, + client_uuid, + phone_id, + username, + } + } + + /// Check if session contains minimum required credentials + pub fn is_valid(&self) -> bool { + !self.session_id.trim().is_empty() && !self.ds_user_id.trim().is_empty() + } + + /// Load session data from a JSON file on disk + pub fn load_from_file>(path: P) -> Result { + let p = path.as_ref(); + let content = fs::read_to_string(p) + .with_context(|| format!("Failed to read session file at {}", p.display()))?; + let session: SessionData = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse session JSON from {}", p.display()))?; + Ok(session) + } + + /// Save session data to a JSON file on disk with restricted permissions + pub fn save_to_file>(&self, path: P) -> Result<()> { + let p = path.as_ref(); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directories for {}", p.display()))?; + } + let json = serde_json::to_string_pretty(self) + .context("Failed to serialize session data to JSON")?; + fs::write(p, json) + .with_context(|| format!("Failed to write session file to {}", p.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o600)); + } + + info!("Session securely saved to {}", p.display()); + Ok(()) + } +} diff --git a/tests/mock_test.rs b/tests/mock_test.rs new file mode 100644 index 0000000..57a848b --- /dev/null +++ b/tests/mock_test.rs @@ -0,0 +1,122 @@ +use insta_note_clock::{InstagramClient, SessionData}; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn test_create_note_success() { + let mock_server = MockServer::start().await; + + // Mock Instagram endpoint POST /notes/create_note + Mock::given(method("POST")) + .and(path("/notes/create_note")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ + "status": "ok", + "id": "18012345678901234", + "text": "๐Ÿ•’ 14:30", + "created_at": 1727364600 + })), + ) + .expect(1) + .mount(&mock_server) + .await; + + let session = SessionData::new( + "mock_session_id_123".to_string(), + "12345678".to_string(), + "mock_csrf_token".to_string(), + Some("testuser".to_string()), + ); + + let client = InstagramClient::new(Some(mock_server.uri()), Some(session), 30, false); + + let resp = client.create_note("๐Ÿ•’ 14:30").await.expect("Note creation should succeed"); + + assert_eq!(resp.status, "ok"); + assert_eq!(resp.id.unwrap(), "18012345678901234"); + assert_eq!(resp.text.unwrap(), "๐Ÿ•’ 14:30"); +} + +#[tokio::test] +async fn test_delete_note_success() { + let mock_server = MockServer::start().await; + + // Mock Instagram endpoint POST /notes/delete_note/ + Mock::given(method("POST")) + .and(path("/notes/delete_note/")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": "ok" + }))) + .expect(1) + .mount(&mock_server) + .await; + + let session = SessionData::new( + "mock_session_id_123".to_string(), + "12345678".to_string(), + "mock_csrf_token".to_string(), + None, + ); + + let client = InstagramClient::new(Some(mock_server.uri()), Some(session), 30, false); + + let result = client.delete_note("18012345678901234").await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_unauthorized_does_not_retry() { + let mock_server = MockServer::start().await; + + // Mock Instagram endpoint POST /notes/create_note returning 401 Unauthorized + Mock::given(method("POST")) + .and(path("/notes/create_note")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "message": "login_required", + "status": "fail" + }))) + .expect(1) // MUST NOT retry upon 401 + .mount(&mock_server) + .await; + + let session = SessionData::new( + "invalid_session".to_string(), + "12345".to_string(), + "token".to_string(), + None, + ); + + let client = InstagramClient::new(Some(mock_server.uri()), Some(session), 30, false); + + let result = client.create_note("๐Ÿ•’ 14:30").await; + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Authentication failed") || err_msg.contains("401")); +} + +#[tokio::test] +async fn test_dry_run_does_not_make_http_calls() { + let mock_server = MockServer::start().await; + + // Ensure 0 calls are made to the mock server in dry-run mode + Mock::given(method("POST")) + .and(path("/notes/create_note")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&mock_server) + .await; + + let session = SessionData::new( + "mock_session_id".to_string(), + "123".to_string(), + "token".to_string(), + None, + ); + + let client = InstagramClient::new(Some(mock_server.uri()), Some(session), 30, true); + + let resp = client.create_note("๐Ÿ•’ 14:30").await.expect("Dry run should succeed"); + assert_eq!(resp.status, "ok"); + assert_eq!(resp.id.unwrap(), "dry-run-note-id"); +}