Compare commits

...
8 Commits
9 changed files with 980 additions and 740 deletions
Generated
+720 -608
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,13 +1,13 @@
[package]
name = "disbahn"
version = "0.1.0"
edition = "2021"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rss = "2.0.12"
reqwest = {version = "0.12.22", default-features = false, features = ["rustls-tls"]}
reqwest = "0.13.4"
tokio = {version = "1.47.0", features = ["rt-multi-thread", "signal"]}
lazy-regex = "3.4.1"
chrono = "0.4.41"
@@ -19,6 +19,7 @@ dotenvy = "0.15.7"
diesel = { version = "2.2.12", features = ["sqlite", "chrono"] }
diesel_migrations = "2.2.0"
getset = "0.1.6"
itertools = "0.15.0"
[dependencies.serenity]
version = "0.12.4"
View File
@@ -0,0 +1 @@
DROP TABLE post_versions;
@@ -0,0 +1,6 @@
CREATE TABLE post_versions (
announcement_id TEXT NOT NULL,
publish_date TIMESTAMP NOT NULL,
title TEXT NOT NULL,
PRIMARY KEY (announcement_id, publish_date, title)
);
+21
View File
@@ -49,3 +49,24 @@ impl<'a> NewPost<'a> {
}
}
}
#[derive(Queryable, Insertable, Getters)]
#[diesel(table_name = post_versions)]
pub struct PostVersion {
#[getset(get = "pub")]
announcement_id: String,
#[getset(get = "pub")]
publish_date: NaiveDateTime,
#[getset(get = "pub")]
title: String,
}
impl PostVersion {
pub fn new(announcement_id: String, publish_date: NaiveDateTime, title: String) -> Self {
Self {
announcement_id,
publish_date,
title,
}
}
}
+10
View File
@@ -1,5 +1,13 @@
// @generated automatically by Diesel CLI.
diesel::table! {
post_versions (announcement_id, publish_date, title) {
announcement_id -> Text,
publish_date -> Timestamp,
title -> Text,
}
}
diesel::table! {
posts (announcement_id, webhook_id) {
announcement_id -> Text,
@@ -8,3 +16,5 @@ diesel::table! {
last_updated -> Timestamp,
}
}
diesel::allow_tables_to_appear_in_same_query!(post_versions, posts,);
+212 -123
View File
@@ -1,18 +1,185 @@
pub mod database;
use crate::database::models::{NewPost, Post};
use crate::database::schema::posts::dsl::posts;
use std::borrow::Cow;
use std::fmt::Write as _;
use crate::database::Database;
use anyhow::{anyhow, Context};
use chrono::{DateTime, NaiveDateTime, TimeZone};
use crate::database::models::{NewPost, Post, PostVersion};
use crate::database::schema::post_versions;
use anyhow::{Context, Ok, anyhow};
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl};
use lazy_regex::{regex, Lazy, Regex};
use itertools::Itertools;
use lazy_regex::{Lazy, Regex, regex};
use log::{debug, error, info};
use reqwest::IntoUrl;
use serenity::all::{CreateEmbed, CreateEmbedFooter, EditWebhookMessage, ExecuteWebhook};
use serenity::all::{
CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter, EditWebhookMessage, ExecuteWebhook,
};
use serenity::http::Http;
use serenity::model::webhook::Webhook;
struct Item<'a> {
guid: &'a str,
title: &'a str,
link: &'a str,
validity_begin: DateTime<Utc>,
validity_end: DateTime<Utc>,
icon: &'a str,
description: &'a str,
pub_datetime: DateTime<Utc>,
}
impl Item<'_> {
fn parse_validity_time(input: &str) -> anyhow::Result<DateTime<Utc>> {
let naive = NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M:%S")?;
let datetime = chrono_tz::Europe::Berlin
.from_local_datetime(&naive)
.unwrap()
.to_utc();
Ok(datetime)
}
fn html_to_discord_markdown(input: &str) -> String {
static RE_TIMES: &Lazy<Regex> = regex!(r#"^.*?<br\s*/>\s*<br\s*/>"#i);
static RE_BOLD: &Lazy<Regex> = regex!(r#"<b\s*>((.|\n)*?)</b\s*>"#i);
static RE_ITALIC: &Lazy<Regex> = regex!(r#"<i\s*>((.|\n)*?)</i\s*>"#i);
static RE_STRIKETHROUGH: &Lazy<Regex> = regex!(r#"<s\s*>((.|\n)*?)</s\s*>"#i);
static RE_NEWLINE: &Lazy<Regex> = regex!(r#"<br\s*/?>"#i);
let input = RE_TIMES.replace(input, "");
let input = RE_BOLD.replace_all(&input, "**$1**");
let input = RE_ITALIC.replace_all(&input, "*$1*");
let input = RE_STRIKETHROUGH.replace_all(&input, "~~$1~~");
let input = RE_NEWLINE.replace_all(&input, "\n");
input.to_string()
}
fn tag(&self) -> Cow<'static, str> {
match self.icon {
"HIM0" => "\u{2139}\u{FE0F} Information".into(),
"HIM1" => "\u{1F6A7} Bauarbeiten".into(),
"HIM2" => "\u{26A0}\u{FE0F} Störung".into(),
"HIM3" => "\u{270A} Streik".into(),
other => format!("\u{1F937} Unbekannte Kategorie {other:?}").into(),
}
}
fn colour(&self) -> u32 {
match self.icon {
"HIM0" => 0x154889,
"HIM1" => 0xf5c211,
"HIM2" => 0xc1121c,
"HIM3" => 0xff6a00,
_ => 0xffffff,
}
}
fn to_embed(&self, mut previous_versions: &[PostVersion]) -> anyhow::Result<CreateEmbed> {
const FOOTER_ICON_URL: &str = "https://www.zuginfo.nrw/img/customer/apple-touch-icon.png";
let mut embed = CreateEmbed::new()
.title(self.title)
.url(self.link)
.author(CreateEmbedAuthor::new(self.tag()))
.colour(self.colour())
.description(Self::html_to_discord_markdown(self.description))
.field(
"Beginn:",
format!("<t:{}:f>", self.validity_begin.timestamp()),
true,
)
.field(
"Ende:",
format!("<t:{}:f>", self.validity_end.timestamp()),
true,
)
.timestamp(self.pub_datetime)
.footer(CreateEmbedFooter::new("zuginfo.nrw").icon_url(FOOTER_ICON_URL));
// Remove trailing titles matching the current title
while let Some(last) = previous_versions.last()
&& last.title() == self.title
{
previous_versions = &previous_versions[..previous_versions.len() - 1]
}
let mut previous_titles_str = String::new();
for version in previous_versions
.iter()
.dedup_by(|&x, &y| x.title() == y.title())
{
writeln!(
&mut previous_titles_str,
"-# * <t:{}:s> {}",
version.publish_date().and_utc().timestamp(),
version.title()
)
.unwrap();
}
if !previous_titles_str.is_empty() {
embed = embed.field("Vorherige Titel:", previous_titles_str, false);
}
Ok(embed)
}
}
impl<'a> TryFrom<&'a rss::Item> for Item<'a> {
type Error = anyhow::Error;
fn try_from(rss_item: &'a rss::Item) -> Result<Self, Self::Error> {
let categories = rss_item.categories();
let guid = rss_item.guid().ok_or(anyhow!("Missing GUID"))?.value();
let title = rss_item.title().ok_or(anyhow!("Missing title"))?;
let link = rss_item.link().ok_or(anyhow!("Missing link"))?;
let validity_begin = &categories
.iter()
.find(|c| c.domain() == Some("validityBegin"))
.ok_or(anyhow!("Missing validityBegin category"))?
.name;
let validity_begin = Self::parse_validity_time(validity_begin)?;
let validity_end = &categories
.iter()
.find(|c| c.domain() == Some("validityEnd"))
.ok_or(anyhow!("Missing validityEnd category"))?
.name;
let validity_end = Self::parse_validity_time(validity_end)?;
let icon = categories
.iter()
.find(|c| c.domain() == Some("icon"))
.map_or("", |c| c.name());
let description = rss_item
.description()
.ok_or(anyhow!("Missing description"))?;
let pub_date_str = rss_item
.pub_date()
.ok_or(anyhow!("Missing publication date"))?;
let pub_datetime = DateTime::parse_from_rfc2822(pub_date_str)
.with_context(|| format!("Unable to parse publication date string {pub_date_str:?}"))?
.naive_utc()
.and_utc();
Ok(Self {
guid,
title,
link,
validity_begin,
validity_end,
icon,
description,
pub_datetime,
})
}
}
pub struct DisbahnClient {
database: Database,
webhook: Webhook,
@@ -36,102 +203,16 @@ impl DisbahnClient {
Ok(channel)
}
fn validity_time_to_timestamp(input: &str) -> anyhow::Result<i64> {
let naive = NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M:%S")?;
let timestamp = chrono_tz::Europe::Berlin
.from_local_datetime(&naive)
.unwrap()
.timestamp();
Ok(timestamp)
}
fn html_to_discord_markdown(input: &str) -> String {
static RE_TIMES: &Lazy<Regex> = regex!(r#"^.*<br\s*/>\s*<br\s*/>"#i);
static RE_BOLD: &Lazy<Regex> = regex!(r#"<b\s*>((.|\n)*?)</b\s*>"#i);
static RE_ITALIC: &Lazy<Regex> = regex!(r#"<i\s*>((.|\n)*?)</i\s*>"#i);
static RE_STRIKETHROUGH: &Lazy<Regex> = regex!(r#"<s\s*>((.|\n)*?)</s\s*>"#i);
static RE_NEWLINE: &Lazy<Regex> = regex!(r#"<br\s*/?>"#i);
let input = RE_TIMES.replace(input, "");
let input = RE_BOLD.replace_all(&input, "**$1**");
let input = RE_ITALIC.replace_all(&input, "*$1*");
let input = RE_STRIKETHROUGH.replace_all(&input, "~~$1~~");
let input = RE_NEWLINE.replace_all(&input, "\n");
input.to_string()
}
fn icon_name_to_url(name: &str) -> &str {
match name {
"HIM1" => "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Zeichen_123_-_Arbeitsstelle%2C_StVO_2013.svg/273px-Zeichen_123_-_Arbeitsstelle%2C_StVO_2013.svg.png",
"HIM2" => "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Zeichen_101_-_Gefahrstelle%2C_StVO_1970.svg/273px-Zeichen_101_-_Gefahrstelle%2C_StVO_1970.svg.png",
"HIM3" => "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Strike_worker.svg/314px-Strike_worker.svg.png",
_ => "https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Zeichen_365-61_-_Informationsstelle%2C_StVO_2013.svg/240px-Zeichen_365-61_-_Informationsstelle%2C_StVO_2013.svg.png",
}
}
fn icon_name_to_colour(name: &str) -> u32 {
match name {
"HIM1" => 0xf5c211,
"HIM2" | "HIM3" => 0xc1121c,
_ => 0x154889,
}
}
fn item_to_embed(item: &rss::Item) -> anyhow::Result<CreateEmbed> {
const FOOTER_ICON_URL: &str = "https://www.zuginfo.nrw/img/customer/apple-touch-icon.png";
let categories = item.categories();
let title = item.title().ok_or(anyhow!("Missing title"))?;
let link = item.link().ok_or(anyhow!("Missing link"))?;
let validity_begin = &categories
.iter()
.find(|c| c.domain() == Some("validityBegin"))
.ok_or(anyhow!("Missing validityBegin category"))?
.name;
let validity_begin = Self::validity_time_to_timestamp(validity_begin)?;
let validity_end = &categories
.iter()
.find(|c| c.domain() == Some("validityEnd"))
.ok_or(anyhow!("Missing validityEnd category"))?
.name;
let validity_end = Self::validity_time_to_timestamp(validity_end)?;
let icon = categories
.iter()
.find(|c| c.domain() == Some("icon"))
.map_or("", |c| c.name());
let icon_url = Self::icon_name_to_url(icon);
let description = Self::html_to_discord_markdown(
item.description().ok_or(anyhow!("Missing description"))?,
);
let pub_date_str = item.pub_date().ok_or(anyhow!("Missing publication date"))?;
let pub_datetime = DateTime::parse_from_rfc2822(pub_date_str)
.with_context(|| format!("Unable to parse publication date string {pub_date_str:?}"))?
.naive_utc()
.and_utc();
let embed = CreateEmbed::new()
.title(title)
.url(link)
.thumbnail(icon_url)
.colour(Self::icon_name_to_colour(icon))
.description(description)
.field("Beginn:", format!("<t:{validity_begin}:f>"), true)
.field("Ende:", format!("<t:{validity_end}:f>"), true)
.timestamp(pub_datetime)
.footer(
CreateEmbedFooter::new(
"Quelle: zuginfo.nrw \u{2013} Alle Angaben ohne Gewehr \u{1F52B}",
)
.icon_url(FOOTER_ICON_URL),
);
Ok(embed)
async fn add_post_version_entry(&mut self, item: &Item<'_>) -> anyhow::Result<()> {
diesel::insert_into(post_versions::table)
.values(PostVersion::new(
item.guid.to_string(),
item.pub_datetime.naive_utc(),
item.title.to_string(),
))
.execute(self.database.conn())
.with_context(|| "Error inserting new post version into database")?;
Ok(())
}
pub async fn refresh(&mut self) -> anyhow::Result<()> {
@@ -153,27 +234,31 @@ impl DisbahnClient {
Ok(())
}
async fn refresh_item(&mut self, item: &rss::Item) -> anyhow::Result<()> {
use crate::database::schema::posts::{self, dsl};
async fn refresh_item(&mut self, rss_item: &rss::Item) -> anyhow::Result<()> {
use crate::database::schema::{post_versions, posts};
let guid = item.guid().ok_or(anyhow!("Missing GUID"))?.value();
let pub_date_str = item.pub_date().ok_or(anyhow!("Missing publication date"))?;
let pub_datetime = DateTime::parse_from_rfc2822(pub_date_str)
.with_context(|| format!("Unable to parse publication date string {pub_date_str:?}"))?
.naive_utc()
.and_utc();
let item: Item = rss_item.try_into()?;
let existing_post: Option<Post> = posts
.filter(dsl::webhook_id.eq(i64::from_le_bytes(self.webhook.id.get().to_le_bytes())))
.filter(dsl::announcement_id.eq(guid))
let existing_post: Option<Post> = posts::dsl::posts
.filter(
posts::dsl::webhook_id.eq(i64::from_le_bytes(self.webhook.id.get().to_le_bytes())),
)
.filter(posts::dsl::announcement_id.eq(item.guid))
.first(self.database.conn())
.optional()
.with_context(|| "Error loading posts from database")?;
if let Some(existing_post) = existing_post {
if existing_post.last_updated().and_utc() < pub_datetime {
info!("Updated item: {guid}");
let embed = Self::item_to_embed(item)?;
if existing_post.last_updated().and_utc() < item.pub_datetime {
info!("Updated item with id {}", item.guid);
let previous_posts = post_versions::dsl::post_versions
.filter(post_versions::dsl::announcement_id.eq(item.guid))
.order_by(post_versions::publish_date)
.load::<PostVersion>(self.database.conn())
.with_context(|| "Error loading previous posts from database")?;
let embed = item.to_embed(&previous_posts)?;
self.webhook
.edit_message(
&self.http,
@@ -183,17 +268,19 @@ impl DisbahnClient {
.await
.with_context(|| "Failed to edit message")?;
diesel::update(posts.find((
guid,
diesel::update(posts::dsl::posts.find((
item.guid,
i64::from_le_bytes(self.webhook.id.get().to_le_bytes()),
)))
.set(dsl::last_updated.eq(pub_datetime.naive_utc()))
.set(posts::dsl::last_updated.eq(item.pub_datetime.naive_utc()))
.execute(self.database.conn())
.with_context(|| "Error updating post in database")?;
self.add_post_version_entry(&item).await?;
}
} else {
info!("New item: {guid}");
let embed = Self::item_to_embed(item)?;
info!("New item with id {}", item.guid);
let embed = item.to_embed(&[])?;
let message = self
.webhook
.execute(&self.http, true, ExecuteWebhook::new().embed(embed))
@@ -203,13 +290,15 @@ impl DisbahnClient {
diesel::insert_into(posts::table)
.values(NewPost::new(
guid,
item.guid,
self.webhook.id,
message.id,
pub_datetime.naive_utc(),
item.pub_datetime.naive_utc(),
))
.execute(self.database.conn())
.with_context(|| "Error inserting new post into database")?;
self.add_post_version_entry(&item).await?;
}
Ok(())
}
+7 -7
View File
@@ -1,6 +1,6 @@
use anyhow::{anyhow, Context};
use disbahn::database::Database;
use anyhow::{Context, anyhow};
use disbahn::DisbahnClient;
use disbahn::database::Database;
use log::error;
use serenity::http::Http;
use std::env;
@@ -15,10 +15,10 @@ fn env_var(name: &str) -> anyhow::Result<String> {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
if let Err(err) = dotenvy::dotenv() {
if !err.not_found() {
return Err(err).context("Unable to load .env file");
}
if let Err(err) = dotenvy::dotenv()
&& !err.not_found()
{
return Err(err).context("Unable to load .env file");
}
env_logger::builder()
@@ -31,7 +31,7 @@ async fn main() -> anyhow::Result<()> {
Some(s) => {
return Err(anyhow!(format!(
"invalid argument `{s}`; the only allowed argument is `daemon`"
)))
)));
}
None => false,
};