initial commit

This commit is contained in:
Fabian Montero 2026-05-16 13:52:32 -06:00
commit a4b8bdd8c0
Signed by: fabian
GPG key ID: 3EDA9AE3937CCDE3
10 changed files with 2644 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

38
CLAUDE.md Normal file
View file

@ -0,0 +1,38 @@
# Telegram Bot
General-purpose Telegram bot with modular handler architecture.
## Build & Run
```bash
cargo build --release
cargo run
```
## Configuration
Bot token must be placed at: `/var/trust/instagram_link_stripper/telegram_token`
## Architecture
- `src/main.rs` - Entry point, dispatcher setup
- `src/config.rs` - Token loading from /var/trust/
- `src/handlers/` - Message handlers (modular, each feature in own file)
- `src/utils/` - Shared utilities
## Adding New Handlers
1. Create `src/handlers/new_feature.rs`
2. Export in `src/handlers/mod.rs`
3. Add handler branch in `handlers::schema()`
## Current Features
### Instagram Link Cleaning (`handlers/instagram.rs`)
Strips tracking parameters from Instagram links:
- `igsh`, `igshid` - Instagram share tracking
- `utm_*` - UTM campaign tracking
- `ref` - Referral tracking
- `fbclid` - Facebook click ID
- `si` - Session identifier

2409
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

11
Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "instagram_link_stripper"
version = "0.1.0"
edition = "2024"
[dependencies]
teloxide = { version = "0.17", default-features = false, features = ["macros", "rustls", "ctrlc_handler"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
url = "2"
log = "0.4"
pretty_env_logger = "0.5"

18
src/config.rs Normal file
View file

@ -0,0 +1,18 @@
use std::fs;
use std::path::Path;
const TOKEN_PATH: &str = "/var/trust/instagram_link_stripper/telegram_token";
pub fn load_token() -> String {
let path = Path::new(TOKEN_PATH);
fs::read_to_string(path)
.unwrap_or_else(|e| {
panic!(
"Failed to read Telegram bot token from {}: {}",
TOKEN_PATH, e
)
})
.trim()
.to_string()
}

56
src/handlers/instagram.rs Normal file
View file

@ -0,0 +1,56 @@
use teloxide::dispatching::UpdateHandler;
use teloxide::prelude::*;
use teloxide::types::ReplyParameters;
use crate::utils::url::strip_tracking_params;
const INSTAGRAM_PATTERN: &str = "instagram.com/";
fn get_message_text(msg: &Message) -> Option<&str> {
msg.text().or_else(|| msg.caption())
}
fn contains_instagram_link(text: &str) -> bool {
text.contains(INSTAGRAM_PATTERN)
}
fn extract_instagram_urls(text: &str) -> Vec<&str> {
text.split_whitespace()
.filter(|word| word.contains(INSTAGRAM_PATTERN))
.collect()
}
async fn handle_instagram_links(
bot: Bot,
msg: Message,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let Some(text) = get_message_text(&msg) else {
return Ok(());
};
let urls = extract_instagram_urls(text);
let cleaned: Vec<String> = urls
.into_iter()
.filter_map(strip_tracking_params)
.collect();
if cleaned.is_empty() {
return Ok(());
}
let reply_text = cleaned.join("\n");
bot.send_message(msg.chat.id, reply_text)
.reply_parameters(ReplyParameters::new(msg.id))
.await?;
Ok(())
}
pub fn handler() -> UpdateHandler<Box<dyn std::error::Error + Send + Sync + 'static>> {
Update::filter_message()
.filter(|msg: Message| {
get_message_text(&msg).map_or(false, contains_instagram_link)
})
.endpoint(handle_instagram_links)
}

8
src/handlers/mod.rs Normal file
View file

@ -0,0 +1,8 @@
pub mod instagram;
use teloxide::dispatching::UpdateHandler;
use teloxide::prelude::*;
pub fn schema() -> UpdateHandler<Box<dyn std::error::Error + Send + Sync + 'static>> {
dptree::entry().branch(instagram::handler())
}

20
src/main.rs Normal file
View file

@ -0,0 +1,20 @@
mod config;
mod handlers;
mod utils;
use teloxide::prelude::*;
#[tokio::main]
async fn main() {
pretty_env_logger::init();
log::info!("Starting bot...");
let token = config::load_token();
let bot = Bot::new(token);
Dispatcher::builder(bot, handlers::schema())
.enable_ctrlc_handler()
.build()
.dispatch()
.await;
}

1
src/utils/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod url;

82
src/utils/url.rs Normal file
View file

@ -0,0 +1,82 @@
use url::Url;
const TRACKING_PARAMS: &[&str] = &[
"igsh",
"igshid",
"utm_source",
"utm_medium",
"utm_campaign",
"utm_content",
"utm_term",
"ref",
"fbclid",
"si",
];
/// Removes tracking parameters from a URL.
/// Returns Some(cleaned_url) if parameters were removed, None if URL was already clean.
pub fn strip_tracking_params(url_str: &str) -> Option<String> {
let mut url = Url::parse(url_str).ok()?;
let original_query = url.query().map(String::from);
let filtered_params: Vec<(String, String)> = url
.query_pairs()
.filter(|(key, _)| !TRACKING_PARAMS.contains(&key.as_ref()))
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
if filtered_params.is_empty() {
url.set_query(None);
} else {
url.query_pairs_mut()
.clear()
.extend_pairs(filtered_params);
}
let new_query = url.query().map(String::from);
if original_query != new_query {
Some(url.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strips_igsh() {
let url = "https://www.instagram.com/reel/ABC123/?igsh=xyz789";
let cleaned = strip_tracking_params(url).unwrap();
assert_eq!(cleaned, "https://www.instagram.com/reel/ABC123/");
}
#[test]
fn test_strips_multiple_params() {
let url = "https://www.instagram.com/p/ABC/?igsh=xyz&utm_source=share&fbclid=123";
let cleaned = strip_tracking_params(url).unwrap();
assert_eq!(cleaned, "https://www.instagram.com/p/ABC/");
}
#[test]
fn test_preserves_non_tracking_params() {
let url = "https://www.instagram.com/p/ABC/?img_index=2&igsh=xyz";
let cleaned = strip_tracking_params(url).unwrap();
assert_eq!(cleaned, "https://www.instagram.com/p/ABC/?img_index=2");
}
#[test]
fn test_returns_none_if_already_clean() {
let url = "https://www.instagram.com/reel/ABC123/";
assert!(strip_tracking_params(url).is_none());
}
#[test]
fn test_returns_none_for_clean_url_with_non_tracking_params() {
let url = "https://www.instagram.com/p/ABC/?img_index=2";
assert!(strip_tracking_params(url).is_none());
}
}