Initial Commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
2053
Cargo.lock
generated
Normal file
2053
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
Normal file
33
Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "discordp2000"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
dirs = "6.0.0"
|
||||
dotenvy = "0.15.7"
|
||||
eventsource-client = "0.14.0"
|
||||
phf = "0.11.3"
|
||||
reqwest = { version = "0.12.14", features = [
|
||||
"json",
|
||||
"rustls-tls",
|
||||
"blocking",
|
||||
"multipart",
|
||||
], default-features = false }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
serenity = { version = "0.12.4", default-features = false, features = [
|
||||
"builder",
|
||||
] }
|
||||
tokio = { version = "1.44.1", features = [
|
||||
"rt",
|
||||
"sync",
|
||||
"macros",
|
||||
"net",
|
||||
"signal",
|
||||
"rt-multi-thread",
|
||||
] }
|
||||
tokio-util = "0.7.14"
|
||||
|
||||
[build-dependencies]
|
||||
phf_codegen = "0.11.3"
|
5
build
Executable file
5
build
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
cargo b -r
|
||||
scp target/release/discordp2000 sdr:~/
|
||||
ssh sdr "sudo systemctl stop discordp2000 ; sudo cp ~/discordp2000 /usr/local/bin/ ; sudo systemctl start discordp2000"
|
60
build.rs
Normal file
60
build.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use std::env;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("capcodes.rs");
|
||||
let mut file = BufWriter::new(std::fs::File::create(&path).unwrap());
|
||||
|
||||
let mut map = phf_codegen::Map::new();
|
||||
|
||||
let capcodelijst = std::fs::read_to_string("./res/capcodelijst.csv")?;
|
||||
|
||||
let mut codes = vec![];
|
||||
|
||||
for line in capcodelijst.lines() {
|
||||
let capcode = line.split(";").next().unwrap_or_default().replace('"', "");
|
||||
if !codes.contains(&capcode) {
|
||||
codes.push(capcode.clone());
|
||||
map.entry(capcode, &parse(line)?);
|
||||
}
|
||||
}
|
||||
|
||||
write!(
|
||||
&mut file,
|
||||
"static CAPCODES: phf::Map<&'static str, CapCode> = {}",
|
||||
map.build()
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
writeln!(&mut file, ";").unwrap();
|
||||
|
||||
println!("cargo::rerun-if-changed=./res/capcodelijst.csv");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut parts = s.split(';');
|
||||
|
||||
let capcode = parts.next().unwrap_or_default().replace('"', "");
|
||||
let service = match parts.next().unwrap_or_default().replace('"', "").as_str() {
|
||||
"Politie" => String::from("Service::Politie"),
|
||||
"Brandweer" => String::from("Service::Brandweer"),
|
||||
"Ambulance" => String::from("Service::Ambulance"),
|
||||
o => format!("Service::Overige(\"{}\")", o),
|
||||
};
|
||||
let region = parts.next().unwrap_or_default().replace('"', "");
|
||||
let location = parts.next().unwrap_or_default().replace('"', "");
|
||||
let description = parts.next().unwrap_or_default().replace('"', "");
|
||||
// "0100005";"Brandweer";"Amsterdam-Amstelland";"Aalsmeer";"Officier van Dienst Aalsmeer/UitHoorn";""
|
||||
|
||||
Ok(format!(
|
||||
r#"CapCode {{
|
||||
capcode: "{capcode}",
|
||||
service: {service},
|
||||
region: "{region}",
|
||||
location: "{location}",
|
||||
description: "{description}",
|
||||
}}"#
|
||||
))
|
||||
}
|
9
config.json.example
Normal file
9
config.json.example
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"webhook_url": "https://discord.com/api/webhooks/xxxxx",
|
||||
"keywords": [
|
||||
"Gelderland Midden",
|
||||
"Arnhem"
|
||||
]
|
||||
}
|
||||
]
|
BIN
res/ambulance.png
Normal file
BIN
res/ambulance.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
res/brandweer.png
Normal file
BIN
res/brandweer.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
9783
res/capcodelijst.csv
Normal file
9783
res/capcodelijst.csv
Normal file
File diff suppressed because it is too large
Load Diff
BIN
res/politie.png
Normal file
BIN
res/politie.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
216
src/main.rs
Normal file
216
src/main.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
mod p2000;
|
||||
|
||||
use std::{fs, io::Write, path::PathBuf, str::FromStr, sync::Arc};
|
||||
|
||||
use eventsource_client::Client;
|
||||
use p2000::Message;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use serenity::{
|
||||
all::{Color, Timestamp},
|
||||
builder::{CreateEmbed, CreateMessage},
|
||||
futures::TryStreamExt,
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
static COLOR_POLITIE: Color = Color::from_rgb(0, 70, 130);
|
||||
static COLOR_BRANDWEER: Color = Color::from_rgb(208, 2, 27);
|
||||
static COLOR_AMBULANCE: Color = Color::from_rgb(241, 221, 56);
|
||||
|
||||
static LOGO_POLITIE: &[u8; 10906] = include_bytes!("../res/politie.png");
|
||||
static LOGO_BRANDWEER: &[u8; 18092] = include_bytes!("../res/brandweer.png");
|
||||
static LOGO_AMBULANCE: &[u8; 15241] = include_bytes!("../res/ambulance.png");
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct Config {
|
||||
webhook_url: String,
|
||||
keywords: Vec<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let mut config_dir = match dirs::config_dir() {
|
||||
Some(mut dir) => {
|
||||
dir.push("p2000");
|
||||
dir
|
||||
}
|
||||
None => PathBuf::from_str(".")?,
|
||||
};
|
||||
|
||||
// create if not exists
|
||||
if let Err(e) = std::fs::create_dir_all(&config_dir) {
|
||||
println!("Failed to create directory: {:?} - {:?}", &config_dir, e);
|
||||
}
|
||||
|
||||
config_dir.push("config.json");
|
||||
|
||||
// create if not exists config file "[]"
|
||||
if !fs::exists(&config_dir)? {
|
||||
let mut file = fs::File::create(&config_dir)?;
|
||||
write!(file, "[]")?;
|
||||
}
|
||||
|
||||
// load config file
|
||||
let config = std::fs::read_to_string(&config_dir)?;
|
||||
let config: Vec<Config> = serde_json::from_str(&config)?;
|
||||
|
||||
if config.is_empty() {
|
||||
return Err(format!(
|
||||
"No webhooks configured in {}",
|
||||
&config_dir.to_string_lossy()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let es = eventsource_client::ClientBuilder::for_url("https://p2000.avii.nl/sse")?.build();
|
||||
let client = es.stream();
|
||||
let mut stream = client
|
||||
.map_ok(|event| {
|
||||
if let eventsource_client::SSE::Event(ev) = event {
|
||||
return Some(ev.data);
|
||||
}
|
||||
None
|
||||
})
|
||||
.map_err(|err| eprintln!("error streaming events: {:?}", err));
|
||||
|
||||
while let Ok(Some(buffer)) = stream.try_next().await {
|
||||
let Some(buffer) = buffer else {
|
||||
continue;
|
||||
};
|
||||
let Ok(message) = Message::from_str(&buffer) else {
|
||||
continue;
|
||||
};
|
||||
let msg_str = message.to_string();
|
||||
println!("{}", msg_str);
|
||||
|
||||
let message = Arc::new(message);
|
||||
|
||||
for c in config.iter() {
|
||||
if c.keywords.is_empty() || c.keywords.iter().any(|keyword| msg_str.contains(keyword)) {
|
||||
let webook_url = c.webhook_url.to_string();
|
||||
let message = message.clone();
|
||||
tokio::spawn(async move {
|
||||
let embed = match create_embed(message) {
|
||||
Ok(embed) => embed,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create embed: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = send(webook_url, embed).await {
|
||||
eprintln!("Error sending message: {:?}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send(url: String, body: Form) -> Result<()> {
|
||||
let client = reqwest::ClientBuilder::new().use_rustls_tls().build()?;
|
||||
client.post(url).multipart(body).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_embed(message: Arc<Message>) -> Result<Form> {
|
||||
let mut embeds = vec![];
|
||||
|
||||
let mut builder = CreateMessage::new();
|
||||
let mut form = reqwest::multipart::Form::new();
|
||||
let mut filenum = 1;
|
||||
|
||||
let mut has_politie = false;
|
||||
let mut has_brandweer = false;
|
||||
let mut has_ambulance = false;
|
||||
|
||||
for capcode in message.capcodes.iter() {
|
||||
let color = match capcode.service {
|
||||
p2000::Service::Politie => COLOR_POLITIE,
|
||||
p2000::Service::Brandweer => COLOR_BRANDWEER,
|
||||
p2000::Service::Ambulance => COLOR_AMBULANCE,
|
||||
p2000::Service::Overige(_) => Color::from_rgb(127, 127, 127),
|
||||
};
|
||||
|
||||
let mut embed = CreateEmbed::new()
|
||||
.color(color)
|
||||
.title(capcode.service.to_string())
|
||||
.description(capcode.capcode)
|
||||
.field(message.message.clone(), "", false)
|
||||
.field("", capcode.region, true)
|
||||
.field("", capcode.location, true)
|
||||
.field("", capcode.description, true);
|
||||
|
||||
match capcode.service {
|
||||
p2000::Service::Politie => {
|
||||
if !has_politie {
|
||||
has_politie = true;
|
||||
form = form.part(
|
||||
format!("file{}", filenum),
|
||||
Part::bytes(LOGO_POLITIE).file_name("politie.png"),
|
||||
);
|
||||
filenum += 1;
|
||||
}
|
||||
embed = embed.thumbnail("attachment://politie.png");
|
||||
}
|
||||
p2000::Service::Brandweer => {
|
||||
if !has_brandweer {
|
||||
has_brandweer = true;
|
||||
form = form.part(
|
||||
format!("file{}", filenum),
|
||||
Part::bytes(LOGO_BRANDWEER).file_name("brandweer.png"),
|
||||
);
|
||||
filenum += 1;
|
||||
}
|
||||
embed = embed.thumbnail("attachment://brandweer.png");
|
||||
}
|
||||
p2000::Service::Ambulance => {
|
||||
if !has_ambulance {
|
||||
has_ambulance = true;
|
||||
form = form.part(
|
||||
format!("file{}", filenum),
|
||||
Part::bytes(LOGO_AMBULANCE).file_name("ambulance.png"),
|
||||
);
|
||||
filenum += 1;
|
||||
}
|
||||
embed = embed.thumbnail("attachment://ambulance.png");
|
||||
}
|
||||
p2000::Service::Overige(_) => (),
|
||||
};
|
||||
|
||||
embed = embed.timestamp(Timestamp::now());
|
||||
embeds.push(embed);
|
||||
}
|
||||
|
||||
if embeds.is_empty() {
|
||||
let mut embed = CreateEmbed::new().color(Color::from_rgb(127, 127, 127));
|
||||
embed = embed.field(message.message.clone(), "", false);
|
||||
|
||||
for code in message.capcodes_raw.split_whitespace() {
|
||||
embed = embed.field("", code, true);
|
||||
}
|
||||
embed = embed.timestamp(Timestamp::now());
|
||||
embeds.push(embed);
|
||||
}
|
||||
|
||||
builder = builder.add_embeds(embeds);
|
||||
|
||||
let json_data = serde_json::to_string_pretty(&builder)?;
|
||||
let mut data = serde_json::from_str::<Value>(&json_data)?;
|
||||
|
||||
data["username"] = serde_json::Value::String("p2000".to_string());
|
||||
data["avatar_url"] =
|
||||
serde_json::Value::String("https://public.avii.nl/.images/p2000_3.png".to_string());
|
||||
|
||||
let json_data = serde_json::to_string_pretty(&data)?;
|
||||
form = form.text("payload_json", json_data);
|
||||
|
||||
Ok(form)
|
||||
}
|
136
src/p2000.rs
Normal file
136
src/p2000.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
// https://www.112centraal.nl/capcodes?codes=000723153
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Service {
|
||||
Politie,
|
||||
Brandweer,
|
||||
Ambulance,
|
||||
Overige(&'static str),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Service {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Service::Politie => "Politie",
|
||||
Service::Brandweer => "Brandweer",
|
||||
Service::Ambulance => "Ambulance",
|
||||
Service::Overige(o) => *o,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CapCode {
|
||||
pub capcode: &'static str,
|
||||
pub region: &'static str,
|
||||
pub location: &'static str,
|
||||
pub service: Service,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/capcodes.rs"));
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug)]
|
||||
pub struct Message {
|
||||
pub raw: String,
|
||||
pub protocol: String,
|
||||
pub timestamp: String,
|
||||
pub flags: String,
|
||||
pub frameid: String,
|
||||
pub capcodes_raw: String,
|
||||
pub capcodes: Vec<&'static CapCode>,
|
||||
pub format: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Message {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.capcodes.is_empty() {
|
||||
write!(f, "[{}]: {}", self.capcodes_raw, self.message)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut iterator = self.capcodes.iter().peekable();
|
||||
while let Some(code) = iterator.next() {
|
||||
write!(f, "[{}]", code.service)?;
|
||||
if !code.region.is_empty() {
|
||||
write!(f, "[{}]", code.region.trim())?;
|
||||
}
|
||||
if !code.location.is_empty() {
|
||||
write!(f, "[{}]", code.location.trim())?;
|
||||
}
|
||||
if !code.description.is_empty() {
|
||||
write!(f, "[{}]", code.description.trim())?;
|
||||
}
|
||||
write!(f, ": ")?;
|
||||
write!(f, "{}", self.message)?;
|
||||
if iterator.peek().is_some() {
|
||||
writeln!(f)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Message {
|
||||
type Err = crate::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
let mut parts = s.split('|'); //.collect::<Vec<&str>>();
|
||||
if parts.clone().count() != 7 {
|
||||
return Err("Invalid message".into());
|
||||
}
|
||||
|
||||
let Some(protocol) = parts.next() else {
|
||||
return Err("Missing Protocol".into());
|
||||
};
|
||||
let Some(timestamp) = parts.next() else {
|
||||
return Err("Missing Timestamp".into());
|
||||
};
|
||||
let Some(flags) = parts.next() else {
|
||||
return Err("Missing Flags".into());
|
||||
};
|
||||
let Some(frameid) = parts.next() else {
|
||||
return Err("Missing FrameID".into());
|
||||
};
|
||||
let Some(capcodes) = parts.next() else {
|
||||
return Err("Missing Cap Codes".into());
|
||||
};
|
||||
let Some(format) = parts.next() else {
|
||||
return Err("Missing Format".into());
|
||||
};
|
||||
let Some(message) = parts.next() else {
|
||||
return Err("Missing Message".into());
|
||||
};
|
||||
|
||||
let mut cc = vec![];
|
||||
let parts = capcodes.split(' ');
|
||||
for c in parts {
|
||||
let c = &c[2..];
|
||||
let Some(cap) = CAPCODES.get(c) else {
|
||||
eprintln!("Missing CapCode: {}", c);
|
||||
continue;
|
||||
};
|
||||
cc.push(cap);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
raw: s.to_string(),
|
||||
protocol: protocol.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
flags: flags.to_string(),
|
||||
frameid: frameid.to_string(),
|
||||
capcodes_raw: capcodes.to_string(),
|
||||
capcodes: cc,
|
||||
format: format.to_string(),
|
||||
message: message.trim().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user