v0.2.0
This commit is contained in:
commit
257b033f03
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
2926
Cargo.lock
generated
Normal file
2926
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "scam-police"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = [ "@0xf8:projectsegfau.lt", "@jjj333:pain.agency" ]
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.70"
|
||||||
|
dirs = "5.0.0"
|
||||||
|
matrix-sdk = "0.6.2"
|
||||||
|
once_cell = "1.17.1"
|
||||||
|
rand = "0.8.5"
|
||||||
|
rpassword = "7.2.0"
|
||||||
|
serde = "1.0.160"
|
||||||
|
serde_json = "1.0.95"
|
||||||
|
tokio = { version = "1.27.0", features = ["macros", "rt-multi-thread"] }
|
||||||
|
url = "2.3.1"
|
51
config/keywords.json
Normal file
51
config/keywords.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"scams":{
|
||||||
|
"verbs":[
|
||||||
|
"earn",
|
||||||
|
"make", "making", "made",
|
||||||
|
"generate",
|
||||||
|
"win",
|
||||||
|
"invest",
|
||||||
|
"cashout",
|
||||||
|
"get",
|
||||||
|
"sex",
|
||||||
|
"meet",
|
||||||
|
"upload",
|
||||||
|
"login",
|
||||||
|
"send"
|
||||||
|
],
|
||||||
|
"currencies":[
|
||||||
|
"$", "£", "€",
|
||||||
|
"money", "millions",
|
||||||
|
"dollars",
|
||||||
|
"pounds",
|
||||||
|
"euros",
|
||||||
|
"crypto",
|
||||||
|
"paypal",
|
||||||
|
"bitcoin", "btc",
|
||||||
|
"etherium", " eth",
|
||||||
|
"free",
|
||||||
|
"meet",
|
||||||
|
"upload",
|
||||||
|
"nudes"
|
||||||
|
],
|
||||||
|
"socials":[
|
||||||
|
"l.wl.co",
|
||||||
|
".app.link/",
|
||||||
|
"bit.ly/",
|
||||||
|
"t.me/",
|
||||||
|
"matrix.to/",
|
||||||
|
"wa.me/",
|
||||||
|
"telegram",
|
||||||
|
"whatsapp", "whatapp", "whats app", "what app",
|
||||||
|
"wickr",
|
||||||
|
"kik",
|
||||||
|
"discord",
|
||||||
|
"instagram",
|
||||||
|
"👇", "👆️",
|
||||||
|
"+1", "+2"
|
||||||
|
],
|
||||||
|
"response":"Warning! This message is likely to be a scam, hoping to lure you in and steal your money. Please visit [ https://www.sec.gov/oiea/investor-alerts-and-bulletins/digital-asset-and-crypto-investment-scams-investor-alert ] for more information. [!mods !modhelp]",
|
||||||
|
"response_md":"Warning! This message is likely to be a <b>scam</b>, hoping to lure you in and steal your money! Please visit <a href=\"https://www.sec.gov/oiea/investor-alerts-and-bulletins/digital-asset-and-crypto-investment-scams-investor-alert\">here</a> for more information. [!mods !modhelp]"
|
||||||
|
}
|
||||||
|
}
|
16
src/config.rs
Normal file
16
src/config.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub struct Config {
|
||||||
|
pub keywords: 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.json");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
keywords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
89
src/keywords.rs
Normal file
89
src/keywords.rs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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<Self,()> {
|
||||||
|
use KeywordCategory::*;
|
||||||
|
|
||||||
|
match var {
|
||||||
|
"verbs" => Ok(Verb),
|
||||||
|
"currencies" => Ok(Currency),
|
||||||
|
"socials" => Ok(Social),
|
||||||
|
_ => Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_counter_map() -> HashMap<KeywordCategory,u64> {
|
||||||
|
use KeywordCategory::*;
|
||||||
|
|
||||||
|
let mut map: HashMap<KeywordCategory,u64> = HashMap::new();
|
||||||
|
map.insert(Verb, 0);
|
||||||
|
map.insert(Currency, 0);
|
||||||
|
map.insert(Social, 0);
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub struct Keywords {
|
||||||
|
pub category: KeywordCategory,
|
||||||
|
pub words: Vec<Value>
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
category,
|
||||||
|
words: v.to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find(&self, hay: &str) -> u64 {
|
||||||
|
let mut hits: u64 = 0;
|
||||||
|
|
||||||
|
for kw in self.words.to_owned().into_iter() {
|
||||||
|
let kw = kw.as_str().unwrap();
|
||||||
|
|
||||||
|
if hay.contains(kw) {
|
||||||
|
hits += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hits
|
||||||
|
}
|
||||||
|
}
|
116
src/main.rs
Normal file
116
src/main.rs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
use matrix_sdk::{
|
||||||
|
room::Room,
|
||||||
|
ruma::{events::room::message::{
|
||||||
|
MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent, Relation
|
||||||
|
}, OwnedRoomId},
|
||||||
|
Error, LoopCtrl,
|
||||||
|
};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
pub mod matrix;
|
||||||
|
pub mod config;
|
||||||
|
pub mod keywords;
|
||||||
|
|
||||||
|
static CONFIG: Lazy<config::Config> = Lazy::new(|| config::Config::load());
|
||||||
|
|
||||||
|
async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) {
|
||||||
|
if let Room::Joined(room) = room {
|
||||||
|
let orig_event = event.to_owned().into_full_event(OwnedRoomId::from(room.room_id()));
|
||||||
|
|
||||||
|
// Ignore non-text
|
||||||
|
let MessageType::Text(text_content) = event.to_owned().content.msgtype else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ignore own messages
|
||||||
|
if event.sender == room.client().user_id().expect("Couldn't get user_id").to_string() { return }
|
||||||
|
|
||||||
|
// Ignore replies
|
||||||
|
if let Some(relation) = orig_event.to_owned().content.relates_to {
|
||||||
|
if match relation {
|
||||||
|
Relation::Reply { in_reply_to: _ } => true,
|
||||||
|
_ => false
|
||||||
|
} {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too short to be a scam lol
|
||||||
|
if text_content.body.len() < 12 { return }
|
||||||
|
|
||||||
|
let text_content = text_content.body.to_lowercase();
|
||||||
|
|
||||||
|
// Load keywords
|
||||||
|
let mut keywords = CONFIG.keywords.clone();
|
||||||
|
let scams = keywords.as_object_mut().unwrap().get_mut("scams").unwrap();
|
||||||
|
|
||||||
|
// Turn json into Keywords
|
||||||
|
let verbs = keywords::Keywords::create("verbs", &scams["verbs"]);
|
||||||
|
let currencies = keywords::Keywords::create("currencies", &scams["currencies"]);
|
||||||
|
let socials = keywords::Keywords::create("socials", &scams["socials"]);
|
||||||
|
|
||||||
|
// Count occurences
|
||||||
|
let mut counter = keywords::KeywordCategory::create_counter_map();
|
||||||
|
counter.insert(keywords::KeywordCategory::Verb, verbs.find(&text_content));
|
||||||
|
counter.insert(keywords::KeywordCategory::Currency, currencies.find(&text_content));
|
||||||
|
counter.insert(keywords::KeywordCategory::Social, socials.find(&text_content));
|
||||||
|
|
||||||
|
// Test if every category has atleast one hit
|
||||||
|
let mut hit_all = true;
|
||||||
|
for (_category, count) in counter.to_owned() {
|
||||||
|
hit_all = hit_all && count != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if hit_all {
|
||||||
|
// Add stats to end of response
|
||||||
|
let response = format!("{}\nDetection stats: {:?}", scams["response"].as_str().unwrap(), counter.to_owned());
|
||||||
|
let response_html = format!("{}<br>Detection stats: <code>{:?}</code>", scams["response"].as_str().unwrap(), counter.to_owned());
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
let msg = RoomMessageEventContent::text_html(response, response_html);
|
||||||
|
let reply = msg.make_reply_to(&orig_event);
|
||||||
|
room.send(reply, None).await.expect("Couldn't send message");
|
||||||
|
|
||||||
|
// Send reaction
|
||||||
|
room.send_raw(json!({
|
||||||
|
"m.relates_to": {
|
||||||
|
"rel_type": "m.annotation",
|
||||||
|
"event_id": orig_event.event_id.to_string(),
|
||||||
|
"key": "🚨🚨 SCAM 🚨🚨"
|
||||||
|
}}), "m.reaction", None).await.expect("Couldn't send reaction");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let data_dir = dirs::data_dir()
|
||||||
|
.expect("no data_dir directory found")
|
||||||
|
.join("scam_police");
|
||||||
|
let session_file = data_dir.join("session");
|
||||||
|
|
||||||
|
let (client, sync_token) = if session_file.exists() {
|
||||||
|
matrix::restore_session(&session_file).await?
|
||||||
|
} else {
|
||||||
|
(matrix::login(&data_dir, &session_file).await?, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (client, sync_settings) = matrix::sync(client, sync_token)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
client.add_event_handler(on_room_message);
|
||||||
|
|
||||||
|
client.sync_with_result_callback(sync_settings, |sync_result| async move {
|
||||||
|
let response = sync_result?;
|
||||||
|
|
||||||
|
matrix::persist_sync_token(response.next_batch)
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::UnknownError(err.into()))?;
|
||||||
|
|
||||||
|
Ok(LoopCtrl::Continue)
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
198
src/matrix.rs
Normal file
198
src/matrix.rs
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
use matrix_sdk::{
|
||||||
|
config::SyncSettings, ruma::api::client::filter::FilterDefinition, Client, Session,
|
||||||
|
};
|
||||||
|
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
io::{self, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ClientSession {
|
||||||
|
homeserver: String,
|
||||||
|
db_path: PathBuf,
|
||||||
|
passphrase: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FullSession {
|
||||||
|
client_session: ClientSession,
|
||||||
|
user_session: Session,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
sync_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore_session(session_file: &Path) -> anyhow::Result<(Client, Option<String>)> {
|
||||||
|
let serialized_session = fs::read_to_string(session_file).await?;
|
||||||
|
let FullSession {
|
||||||
|
client_session,
|
||||||
|
user_session,
|
||||||
|
sync_token,
|
||||||
|
} = serde_json::from_str(&serialized_session)?;
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.homeserver_url(client_session.homeserver)
|
||||||
|
.sled_store(client_session.db_path, Some(&client_session.passphrase))
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("[*] Restoring session for {}…", user_session.user_id);
|
||||||
|
|
||||||
|
client.restore_login(user_session).await?;
|
||||||
|
|
||||||
|
Ok((client, sync_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(data_dir: &Path, session_file: &Path) -> anyhow::Result<Client> {
|
||||||
|
println!("[*] No previous session found, logging in…");
|
||||||
|
|
||||||
|
let (client, client_session) = build_client(data_dir).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut user = String::new();
|
||||||
|
io::stdout().flush().expect("Unable to write to stdout");
|
||||||
|
io::stdin()
|
||||||
|
.read_line(&mut user)
|
||||||
|
.expect("Unable to read user input");
|
||||||
|
let password = rpassword::prompt_password("Password> ").unwrap();
|
||||||
|
|
||||||
|
match client
|
||||||
|
.login_username(&user, &password)
|
||||||
|
.initial_device_display_name("vcxz bot")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
println!("[*] Logged in as {user}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
println!("[!] Error logging in: {error}");
|
||||||
|
println!("[!] Please try again\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_session = client
|
||||||
|
.session()
|
||||||
|
.expect("A logged-in client should have a session");
|
||||||
|
let serialized_session = serde_json::to_string(&FullSession {
|
||||||
|
client_session,
|
||||||
|
user_session,
|
||||||
|
sync_token: None,
|
||||||
|
})?;
|
||||||
|
fs::write(session_file, serialized_session).await?;
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a new client.
|
||||||
|
pub async fn build_client(data_dir: &Path) -> anyhow::Result<(Client, ClientSession)> {
|
||||||
|
let mut rng = thread_rng();
|
||||||
|
|
||||||
|
let db_subfolder: String = (&mut rng)
|
||||||
|
.sample_iter(Alphanumeric)
|
||||||
|
.take(7)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
let db_path = data_dir.join(db_subfolder);
|
||||||
|
|
||||||
|
let passphrase: String = (&mut rng)
|
||||||
|
.sample_iter(Alphanumeric)
|
||||||
|
.take(32)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut homeserver = String::new();
|
||||||
|
|
||||||
|
print!("Homeserver> ");
|
||||||
|
io::stdout().flush().expect("Unable to write to stdout");
|
||||||
|
io::stdin()
|
||||||
|
.read_line(&mut homeserver)
|
||||||
|
.expect("Unable to read user input");
|
||||||
|
|
||||||
|
println!("[*] Checking homeserver…");
|
||||||
|
|
||||||
|
match Client::builder()
|
||||||
|
.homeserver_url(&homeserver)
|
||||||
|
.sled_store(&db_path, Some(&passphrase))
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(client) => {
|
||||||
|
return Ok((
|
||||||
|
client,
|
||||||
|
ClientSession {
|
||||||
|
homeserver,
|
||||||
|
db_path,
|
||||||
|
passphrase,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(error) => match &error {
|
||||||
|
matrix_sdk::ClientBuildError::AutoDiscovery(_)
|
||||||
|
| matrix_sdk::ClientBuildError::Url(_)
|
||||||
|
| matrix_sdk::ClientBuildError::Http(_) => {
|
||||||
|
println!("[!] Error checking the homeserver: {error}");
|
||||||
|
println!("[!] Please try again\n");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync<'a>(
|
||||||
|
client: Client,
|
||||||
|
initial_sync_token: Option<String>,
|
||||||
|
) -> anyhow::Result<(Client, SyncSettings<'a>)> {
|
||||||
|
println!("[*] Initial sync...");
|
||||||
|
|
||||||
|
let filter = FilterDefinition::empty();
|
||||||
|
|
||||||
|
let mut sync_settings = SyncSettings::default().filter(filter.into());
|
||||||
|
|
||||||
|
if let Some(sync_token) = initial_sync_token {
|
||||||
|
sync_settings = sync_settings.token(sync_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match client.sync_once(sync_settings.clone()).await {
|
||||||
|
Ok(response) => {
|
||||||
|
sync_settings = sync_settings.token(response.next_batch.clone());
|
||||||
|
persist_sync_token(response.next_batch).await?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
println!("[!] An error occurred during initial sync: {error}");
|
||||||
|
println!("[!] Trying again…");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("[*] The bot is ready!");
|
||||||
|
|
||||||
|
Ok((client, sync_settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn persist_sync_token(sync_token: String) -> anyhow::Result<()> {
|
||||||
|
let data_dir = dirs::data_dir()
|
||||||
|
.expect("no data_dir directory found")
|
||||||
|
.join("scam_police");
|
||||||
|
let session_file = data_dir.join("session");
|
||||||
|
|
||||||
|
let serialized_session = fs::read_to_string(&session_file).await?;
|
||||||
|
let mut full_session: FullSession = serde_json::from_str(&serialized_session)?;
|
||||||
|
|
||||||
|
full_session.sync_token = Some(sync_token);
|
||||||
|
let serialized_session = serde_json::to_string(&full_session)?;
|
||||||
|
fs::write(session_file, serialized_session).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user