Initial commit: insta-note-clock implementation with tests
This commit is contained in:
commit
c23d128186
13 changed files with 1718 additions and 0 deletions
35
.env.example
Normal file
35
.env.example
Normal file
|
|
@ -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
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
.env
|
||||
session.json
|
||||
31
Cargo.toml
Normal file
31
Cargo.toml
Normal file
|
|
@ -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"
|
||||
194
README.md
Normal file
194
README.md
Normal file
|
|
@ -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":"<note_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:<ts>:<pwd>",...}` | 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 <repo-url> 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.
|
||||
41
config.example.toml
Normal file
41
config.example.toml
Normal file
|
|
@ -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
|
||||
41
config.toml
Normal file
41
config.toml
Normal file
|
|
@ -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
|
||||
520
src/client.rs
Normal file
520
src/client.rs
Normal file
|
|
@ -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<Sha256>;
|
||||
|
||||
/// 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<Instant>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub text: Option<String>,
|
||||
pub created_at: Option<i64>,
|
||||
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<Value>,
|
||||
pub obfuscated_phone_number: Option<String>,
|
||||
}
|
||||
|
||||
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<Mutex<Option<SessionData>>>,
|
||||
pub rate_limiter: Arc<Mutex<RateLimiter>>,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
impl InstagramClient {
|
||||
pub fn new(base_url: Option<String>, session: Option<SessionData>, 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.<json_payload>`.
|
||||
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<LoginResult> {
|
||||
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:<timestamp>:<password>`
|
||||
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<SessionData> {
|
||||
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<Value> {
|
||||
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<NoteResponse> {
|
||||
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<Value> {
|
||||
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
|
||||
}
|
||||
}
|
||||
80
src/clock.rs
Normal file
80
src/clock.rs
Normal file
|
|
@ -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, ClockError> {
|
||||
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<Utc>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
232
src/config.rs
Normal file
232
src/config.rs
Normal file
|
|
@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub ds_user_id: Option<String>,
|
||||
#[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<Self> {
|
||||
// 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<Tz> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
|
|
@ -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;
|
||||
323
src/main.rs
Normal file
323
src/main.rs
Normal file
|
|
@ -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<PathBuf>,
|
||||
|
||||
/// 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<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 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<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!("\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(())
|
||||
}
|
||||
85
src/session.rs
Normal file
85
src/session.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
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<P: AsRef<Path>>(&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(())
|
||||
}
|
||||
}
|
||||
122
tests/mock_test.rs
Normal file
122
tests/mock_test.rs
Normal file
|
|
@ -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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue