Initial commit: insta-note-clock implementation with tests

This commit is contained in:
katR 2026-09-26 20:41:21 +05:30
commit c23d128186
13 changed files with 1718 additions and 0 deletions

35
.env.example Normal file
View 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
View file

@ -0,0 +1,5 @@
/target
**/*.rs.bk
Cargo.lock
.env
session.json

31
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(&note_text).await?;
info!(
"✅ Note active! Text: \"{}\" (Note ID: {})",
resp.text.unwrap_or(note_text),
resp.id.unwrap_or_else(|| "N/A".to_string())
);
Ok(())
}
/// Continuous clock update loop with graceful Ctrl+C shutdown
async fn run_clock_loop(client: &InstagramClient, config: &AppConfig) -> Result<()> {
let tz = config.parse_timezone()?;
let interval = Duration::from_secs(config.note.interval_seconds);
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(&note_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
View 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
View 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");
}