diff --git a/Cargo.lock b/Cargo.lock index 8d184c7..b5441e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2067,7 +2067,7 @@ checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" [[package]] name = "scam-police" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "dirs", @@ -2078,6 +2078,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "strfmt", "tokio", "url", ] @@ -2252,6 +2253,12 @@ dependencies = [ "der", ] +[[package]] +name = "strfmt" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8348af2d9fc3258c8733b8d9d8db2e56f54b2363a4b5b81585c7875ed65e65" + [[package]] name = "strsim" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 1c931c3..4119bf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scam-police" -version = "0.5.0" +version = "0.6.0" edition = "2021" authors = [ "@0xf8:projectsegfau.lt", "@jjj333:pain.agency" ] @@ -16,5 +16,6 @@ reqwest = "0.11.16" rpassword = "7.2.0" serde = "1.0.160" serde_json = "1.0.95" +strfmt = "0.2.4" tokio = { version = "1.27.0", features = ["macros", "rt-multi-thread"] } url = "2.3.1" diff --git a/config/keywords.json b/config/keywords.json index 4c57e6d..6424f66 100644 --- a/config/keywords.json +++ b/config/keywords.json @@ -1,66 +1,52 @@ { - "keywords":{ - "verbs":[ - "earn", - "make", "making", "made", - "generate", - "win", - "invest", - "cashout", - "sell", - "get", - "pay", - "sex", - "meet", - "upload", - "login", - "send", - "join", - "buy", - "check", - "private" - ], - "currencies":[ - "$", "£", "€", - "money", - "million", - "dollar", "pound", "euro", - "crypto", - "paypal", - "bitcoin", "btc", - "etherium", " eth", - "usd", - "nft", - "token", - "free", - "gift", - "card", - "nude", - "18+", - "pay" - ], - "socials":[ - "l.wl.co/", - ".app.link/", - "bit.ly/", - "paypal.me/", - "matrix.to/", - "wa.me/", - "t.me/", - "cash.app", - "cash app", - "cashapp", - "discord.gg/", - "discord", - "is.gd/", - "telegram", - "whatsapp", "whatapp", "whats app", "what app", - "wickr", - "kik", - "instagram", - "dm me", - "👇", "👆️", - "+1", "+2" - ] + "sections": { + "18+": { + "threshold": 3, + "requiredKeywords": [ + "sex", + "nude", + "18+", + "private", + "pictures", + "cheap" + ], + "keywords": [ + "pay", "pal", + "buy", + "sell", + "message", + "meet", + "check", + "card" + ] + }, + "Investment": { + "threshold": 4, + "requiredKeywords": [ + "tg", "t.me/", + "invest", + "crypto", "market", + "profit", + "my commission", + "cashout", "cash out", + "million", + "cash.app", + "l.wl.co/", ".app.link/" + ], + "keywords": [ + "earn", "earning", + "make", "making", "made", + "buy", + "send", + "interested", + "btc", "bitcoin", + "eth", "ethereum", "etherium", + "$", "usd", + "asking me how", + "cash", "whats", "app", + "tele", "gram", + "👇", "👆️" + ] + } } } diff --git a/config/responses.json b/config/responses.json index b0e2cb5..9f976dd 100644 --- a/config/responses.json +++ b/config/responses.json @@ -3,16 +3,16 @@ "Ok": null, "MaybeScam": null, "LikelyScam": { - "plain": "Watch out, the message you replied to has been detected as a scam! Please don't do anything they ask you to do! Stay safe", - "html": "Watch out, the message you replied to has been detected as a scam! Please don't do anything they ask you to do! Stay safe" + "plain": "Watch out, the message you replied to has been detected as an {scam} scam! Please don't do anything they ask you to do! Stay safe", + "html": "Watch out, the message you replied to has been detected as an {scam} scam! Please don't do anything they ask you to do! Stay safe" } }, "message": { "Ok": null, "MaybeScam": null, "LikelyScam": { - "plain": "Warning! This message is likely to be a scam, seeking to lure you in and steal your money! Please visit these resources for more information:\n- https://www.sec.gov/oiea/investor-alerts-and-bulletins/digital-asset-and-crypto-investment-scams-investor-alert\n- https://www.youtube.com/watch?v=gFWaA7mt9oM \n [!mods !modhelp]", - "html": "Warning! This message is likely to be a scam, seeking to lure you in and steal your money! Please visit these resources for more information: [!mods !modhelp]" + "plain": "Warning! This message was detected as an {scam} scam, seeking to lure you in and steal your money! Please visit these resources for more information:\n- https://www.sec.gov/oiea/investor-alerts-and-bulletins/digital-asset-and-crypto-investment-scams-investor-alert\n- https://www.youtube.com/watch?v=gFWaA7mt9oM \n [!mods !modhelp]", + "html": "Warning! This message was detected as an {scam} scam, seeking to lure you in and steal your money! Please visit these resources for more information: [!mods !modhelp]" } } } \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index dfa2e7e..a3886e1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,11 @@ -use serde_json::Value; +use crate::keywords::KeywordSection; +use matrix_sdk::{room::Joined, ruma::OwnedRoomId}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; -use matrix_sdk::{ruma::OwnedRoomId, room::Joined}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; use tokio::fs; + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] pub struct RoomConfig { #[serde(skip_serializing_if = "Option::is_none")] @@ -16,7 +18,11 @@ pub struct RoomConfig { impl Default for RoomConfig { fn default() -> Self { - Self { id: None, no_reply: false, reply_to_scam: false } + Self { + id: None, + no_reply: false, + reply_to_scam: false, + } } } @@ -30,7 +36,9 @@ impl RoomConfigController { pub async fn find(&self, id: OwnedRoomId) -> Option<&RoomConfig> { let id = Some(id); for room in self.rooms.to_owned() { - if room.id == id { return Some(self.rooms.get(&room).unwrap()) } + if room.id == id { + return Some(self.rooms.get(&room).unwrap()); + } } None @@ -45,7 +53,11 @@ impl RoomConfigController { Ok(self.rooms.insert(config)) } - pub async fn find_or_create(&mut self, room: &Joined, id: OwnedRoomId) -> anyhow::Result<&RoomConfig> { + pub async fn find_or_create( + &mut self, + room: &Joined, + id: OwnedRoomId, + ) -> anyhow::Result<&RoomConfig> { if self.find(id.to_owned()).await.is_none() { self.create_config(room).await?; } @@ -54,7 +66,12 @@ impl RoomConfigController { } pub async fn save(&mut self, config: &RoomConfig) -> anyhow::Result<()> { - self.rooms.take(self.to_owned().find(config.id.to_owned().unwrap()).await.unwrap()); + self.rooms.take( + self.to_owned() + .find(config.id.to_owned().unwrap()) + .await + .unwrap(), + ); self.rooms.insert(config.to_owned()); let serialized = serde_json::to_string_pretty(self)?; @@ -65,13 +82,18 @@ impl RoomConfigController { pub fn restore() -> anyhow::Result { if !std::path::Path::new(crate::ROOMS_CONFIG_FILE.to_owned().to_str().unwrap()).exists() { - return Ok(Self { rooms: BTreeSet::::new() }); + return Ok(Self { + rooms: BTreeSet::::new(), + }); } let serialized = std::fs::read_to_string(crate::ROOMS_CONFIG_FILE.to_owned())?; - let ctrl: Result = serde_json::from_str(&serialized); + let ctrl: Result = + serde_json::from_str(&serialized); if ctrl.is_err() { - return Ok(Self { rooms: BTreeSet::::new() }); + return Ok(Self { + rooms: BTreeSet::::new(), + }); } Ok(ctrl.unwrap()) } @@ -80,24 +102,31 @@ impl RoomConfigController { #[derive(Debug)] pub struct Config { - pub keywords: Value, + pub keywords: BTreeMap, pub responses: Value, } impl Config { pub fn load() -> Config { - let keywords_reader = - std::fs::File::open("config/keywords.json").expect("Couldn't find keywords.json"); - let keywords: Value = - serde_json::from_reader(keywords_reader).expect("Couldn't read keywords"); + let responses: Value = serde_json::from_reader( + std::fs::File::open("config/responses.json").expect("Couldn't find responses.json"), + ) + .expect("Couldn't read responses"); - let responses_reader = - std::fs::File::open("config/responses.json").expect("Couldn't find responses.json"); - let responses: Value = - serde_json::from_reader(responses_reader).expect("Couldn't read responses"); + let keywords: Value = serde_json::from_reader( + std::fs::File::open("config/keywords.json").expect("Couldn't find keywords.json"), + ) + .expect("Couldn't read keywords"); + + let sections: BTreeMap = keywords["sections"] + .as_object() + .unwrap() + .into_iter() + .map(|a| (a.0.to_owned(), KeywordSection::load(a.1))) + .collect(); Self { - keywords, + keywords: sections, responses, } } diff --git a/src/debug.rs b/src/debug.rs new file mode 100644 index 0000000..ac3b0f7 --- /dev/null +++ b/src/debug.rs @@ -0,0 +1,50 @@ +use matrix_sdk::{ + room::Joined, + ruma::events::room::message::RoomMessageEventContent, +}; +use crate::{ + judge::{Judgement, JudgementResult}, + keywords::KeywordSection, + CONFIG, +}; + +pub struct Debug {} + +impl Debug { + pub async fn send_debug(judge: &Judgement, room: &Joined) -> anyhow::Result<()> { + let sections = CONFIG.keywords.to_owned(); + + let mut result_report: Vec<(String, String)> = vec![]; + + let res: (JudgementResult, Option) = (|| { + for section in sections { + let (hits, hit_required) = section.1.find(judge.text.to_owned()); + + result_report.push(( + format!("\"{}\": \"{hits}\", {hit_required} ", section.0), + format!("{}: {hits}, {hit_required}", section.0) + )); + + if hit_required && hits >= section.1.threshold { + return (JudgementResult::LikelyScam, Some(section.1)) + } else if hits >= section.1.threshold { + return (JudgementResult::MaybeScam, Some(section.1)) + } + } + + (JudgementResult::Ok, None) + })(); + + let mut full_report: (String, String) = ("".to_string(), "".to_string()); + + for (plain, html) in result_report { + full_report.0.push_str(format!("{plain}\n").as_str()); + full_report.1.push_str(format!("{html}
").as_str()); + } + + let msg = RoomMessageEventContent::text_html(full_report.0, full_report.1); + room.send(msg, None).await.expect("Couldn't send message"); + + Ok(()) + } +} \ No newline at end of file diff --git a/src/judge.rs b/src/judge.rs index 445987a..1715a09 100644 --- a/src/judge.rs +++ b/src/judge.rs @@ -1,5 +1,5 @@ use crate::{ - keywords::{KeywordCategory, Keywords}, + keywords::KeywordSection, config::RoomConfig, CONFIG, }; @@ -7,9 +7,11 @@ use matrix_sdk::{ room::Joined, ruma::events::room::message::{OriginalRoomMessageEvent, RoomMessageEventContent}, }; +use std::collections::HashMap; +use strfmt::strfmt; use serde_json::json; -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub enum JudgementResult { Ok, MaybeScam, // hit atleast one category @@ -31,93 +33,27 @@ pub struct Judgement { } impl Judgement { - pub fn judge(&self, config: &RoomConfig) -> anyhow::Result { - // Load keywords - let mut keywords = CONFIG.keywords.clone(); - let keywords = keywords - .as_object_mut() - .unwrap() - .get_mut("keywords") - .unwrap(); + pub fn judge(&self, _config: &RoomConfig) -> anyhow::Result<(JudgementResult, Option<(String, KeywordSection)>)> { + let sections = CONFIG.keywords.to_owned(); - // Turn json into Keywords - let verbs = Keywords::create("verbs", &keywords["verbs"]); - let currencies = Keywords::create("currencies", &keywords["currencies"]); - let socials = Keywords::create("socials", &keywords["socials"]); + for section in sections { + let (hits, hit_required) = section.1.find(self.text.to_owned()); - // Count occurences - let mut counter = KeywordCategory::create_counter_map(); - counter.insert(KeywordCategory::Verb, verbs.find(&self.text)); - counter.insert(KeywordCategory::Currency, currencies.find(&self.text)); - counter.insert(KeywordCategory::Social, socials.find(&self.text)); - - let mut count_all = 0; - let total = counter.len(); - for (_category, count) in counter.to_owned() { - if count > 0 { - count_all = count_all + 1; + if hit_required && hits >= section.1.threshold { + return Ok((JudgementResult::LikelyScam, Some(section))) + } else if hits >= section.1.threshold { + return Ok((JudgementResult::MaybeScam, Some(section))) } } - - if count_all == 0 { - return Ok(JudgementResult::Ok); - }; - if count_all < total { - return Ok(JudgementResult::MaybeScam); - }; - Ok(JudgementResult::LikelyScam) - } - - pub async fn send_debug(&self, config: &RoomConfig, room: &Joined) -> anyhow::Result<()> { - // Load keywords - let mut keywords = CONFIG.keywords.clone(); - let keywords = keywords - .as_object_mut() - .unwrap() - .get_mut("keywords") - .unwrap(); - - // Turn json into Keywords - let verbs = Keywords::create("verbs", &keywords["verbs"]); - let currencies = Keywords::create("currencies", &keywords["currencies"]); - let socials = Keywords::create("socials", &keywords["socials"]); - - // Count occurences - let mut counter = KeywordCategory::create_counter_map(); - counter.insert(KeywordCategory::Verb, verbs.find(&self.text)); - counter.insert(KeywordCategory::Currency, currencies.find(&self.text)); - counter.insert(KeywordCategory::Social, socials.find(&self.text)); - - let mut count_all = 0; - let total = counter.len(); - for (_category, count) in counter.to_owned() { - if count > 0 { - count_all = count_all + 1; - } - } - - let mut result = JudgementResult::LikelyScam; - if count_all < total { - result = JudgementResult::MaybeScam - } - if count_all == 0 { - result = JudgementResult::Ok - } - // Send message - let msg = RoomMessageEventContent::text_html( - format!("{counter:?}\nCategories covered: {count_all}/{total}\nVerdict: {result:?}\n{config:?}"), - format!("{counter:?}
Categories covered: {count_all}/{total}
Verdict: {result:?}
{config:?}")); - room.send(msg, None).await.expect("Couldn't send message"); - - Ok(()) + Ok((JudgementResult::Ok, None)) } pub async fn alert( config: &RoomConfig, room: &Joined, event: &OriginalRoomMessageEvent, - result: JudgementResult, + result: (JudgementResult, Option<(String, KeywordSection)>), is_reply: bool, ) -> anyhow::Result<()> { if config.no_reply { @@ -129,44 +65,45 @@ impl Judgement { // Determine which message to send let section = if is_reply { - responses["reply"].as_object().unwrap() + responses["reply"].to_owned() } else { - responses["message"].as_object().unwrap() + responses["message"].to_owned() }; - let response_type = section.get(result.to_json_var()).unwrap(); + let response_type = section.get(result.0.to_json_var()).unwrap(); if response_type.is_null() { anyhow::bail!("Called alert with result that has no detection message"); } - let response_type = response_type.as_object().unwrap(); - let plain = response_type["plain"].as_str().unwrap(); - let html = response_type["html"].as_str().unwrap(); + let scam_type = result.1.unwrap().0; + let mut args: HashMap = HashMap::new(); + args.insert("scam".to_string(), &scam_type); + let plain = strfmt(response_type["plain"].as_str().unwrap(), &args).unwrap(); + let html = strfmt(response_type["html"].as_str().unwrap(), &args).unwrap(); // Send message let msg = RoomMessageEventContent::text_html(plain, html); - if config.reply_to_scam { - let reply = msg.make_reply_to(event); - room.send(reply, None).await.expect("Couldn't send message"); + let msg = if config.reply_to_scam { + msg.make_reply_to(event) } else { - room.send(msg, None).await.expect("Couldn't send message"); - } + msg + }; + room.send(msg, None).await.expect("Couldn't send message"); // Send reaction if !is_reply { room.send_raw( json!({ - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": event.event_id.to_string(), - "key": "🚨🚨 SCAM 🚨🚨" - }}), - "m.reaction", - None, - ) - .await - .expect("Couldn't send reaction"); + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": event.event_id.to_string(), + "key": "🚨🚨 SCAM 🚨🚨" + }}), + "m.reaction", + None) + .await + .expect("Couldn't send reaction"); } Ok(()) diff --git a/src/keywords.rs b/src/keywords.rs index eb2f634..3df3bfd 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -1,87 +1,53 @@ use serde_json::Value; -use std::collections::HashMap; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum KeywordCategory { - Verb, - Currency, - Social, -} -impl std::fmt::Display for KeywordCategory { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - use KeywordCategory::*; - match self { - Verb => write!(f, "Verb"), - Currency => write!(f, "Currency"), - Social => write!(f, "Social"), - } - } +#[derive(Debug, Clone)] +pub struct KeywordSection { + pub threshold: u64, + pub required: Vec, + pub keywords: Vec, } -impl KeywordCategory { - pub fn to_json_var(&self) -> &str { - use KeywordCategory::*; - - match self { - Verb => "verbs", - Currency => "currencies", - Social => "socials", - } - } - - pub fn from_json_var(var: &str) -> Result { - use KeywordCategory::*; - - match var { - "verbs" => Ok(Verb), - "currencies" => Ok(Currency), - "socials" => Ok(Social), - _ => Err(()), - } - } - - pub fn create_counter_map() -> HashMap { - use KeywordCategory::*; - - let mut map: HashMap = HashMap::new(); - map.insert(Verb, 0); - map.insert(Currency, 0); - map.insert(Social, 0); - - map - } -} - -pub struct Keywords { - pub category: KeywordCategory, - pub words: Vec, -} - -impl Keywords { - pub fn create(name: &str, v: &Value) -> Self { - let v = v.as_array().unwrap(); - let Ok(category) = KeywordCategory::from_json_var(name) else { - panic!("Couldn't translate \"{name}\" to KeywordCategory"); - }; +impl KeywordSection { + pub fn load(json: &Value) -> Self { + let threshold: u64 = json["threshold"].as_u64().unwrap(); + let required: Vec = json["requiredKeywords"] + .as_array() + .unwrap() + .into_iter() + .map(|a| a.as_str().unwrap().to_string()) + .collect(); + let keywords: Vec = json["keywords"] + .as_array() + .unwrap() + .into_iter() + .map(|a| a.as_str().unwrap().to_string()) + .collect(); Self { - category, - words: v.to_vec(), + threshold, + required, + keywords, } } - pub fn find(&self, hay: &str) -> u64 { + pub fn find(&self, s: String) -> (u64, bool) { let mut hits: u64 = 0; + let mut hit_required: bool = false; - for kw in self.words.to_owned().into_iter() { - let kw = kw.as_str().unwrap(); - - if hay.contains(kw) { - hits += 1 + for kw in self.keywords.to_owned() { + if s.contains(&kw) { + hits += 1; } } - hits + for rkw in self.required.to_owned() { + if s.contains(&rkw) { + hits += 1; + hit_required = true; + } + } + + (hits, hit_required) } -} +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 10cf64b..7eab6db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,8 @@ pub mod config; pub mod judge; pub mod keywords; pub mod matrix; +pub mod debug; + static DATA_DIR: Lazy = Lazy::new(|| dirs::data_dir().expect("No data_dir found").join("scam_police")); static SESSION_FILE: Lazy = Lazy::new(|| DATA_DIR.join("session")); @@ -22,6 +24,7 @@ static ROOMS_CONFIG_FILE: Lazy = Lazy::new(|| DATA_DIR.join("rooms_conf static CONFIG: Lazy = Lazy::new(|| config::Config::load()); + async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) -> anyhow::Result<()> { if let Room::Joined(room) = room { let mut room_ctrl = config::RoomConfigController::restore().expect("Couldn't restore room configs"); @@ -108,11 +111,12 @@ async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) -> any let reply_judgement = judge::Judgement { text: content }; if debug { - reply_judgement.send_debug(&config, &room).await?; + debug::Debug::send_debug(&reply_judgement, &room).await?; return Ok(()); } - match reply_judgement.judge(&config)? { + let judgement = reply_judgement.judge(&config)?; + match judgement.0 { judge::JudgementResult::Ok => (), judge::JudgementResult::MaybeScam => (), judge::JudgementResult::LikelyScam => { @@ -120,7 +124,7 @@ async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) -> any &config, &room, &orig_event, - judge::JudgementResult::LikelyScam, + judgement, true, ) .await?; @@ -130,7 +134,8 @@ async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) -> any } } - match judgement.judge(&config)? { + let judgement = judgement.judge(&config)?; + match judgement.0 { judge::JudgementResult::Ok => return Ok(()), judge::JudgementResult::MaybeScam => return Ok(()), judge::JudgementResult::LikelyScam => { @@ -138,7 +143,7 @@ async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) -> any &config, &room, &orig_event, - judge::JudgementResult::LikelyScam, + judgement, false, ) .await?;