Initial Commit

This commit is contained in:
2025-03-15 12:26:31 +01:00
commit 178d0b2e28
12 changed files with 12296 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2053
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

33
Cargo.toml Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
[
{
"webhook_url": "https://discord.com/api/webhooks/xxxxx",
"keywords": [
"Gelderland Midden",
"Arnhem"
]
}
]

BIN
res/ambulance.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
res/brandweer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

9783
res/capcodelijst.csv Normal file

File diff suppressed because it is too large Load Diff

BIN
res/politie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

216
src/main.rs Normal file
View 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
View 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(),
})
}
}