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?;