From 3e31f4d1f9446e19ec9da117b7a6eb6660c2a36b Mon Sep 17 00:00:00 2001 From: Niklas Elsbrock Date: Tue, 28 Jul 2026 16:50:16 +0200 Subject: [PATCH] add list for previous titles --- Cargo.lock | 10 + Cargo.toml | 3 +- migrations/.keep | 0 .../down.sql | 1 + .../up.sql | 6 + src/bin/disbahn.rs | 14 +- src/database/models.rs | 21 ++ src/database/schema.rs | 10 + src/lib.rs | 321 +++++++++++------- 9 files changed, 261 insertions(+), 125 deletions(-) delete mode 100644 migrations/.keep create mode 100644 migrations/2026-07-28-124138-0000_create_post_versions/down.sql create mode 100644 migrations/2026-07-28-124138-0000_create_post_versions/up.sql diff --git a/Cargo.lock b/Cargo.lock index 90e2246..20e2b3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -632,6 +632,7 @@ dependencies = [ "dotenvy", "env_logger", "getset", + "itertools", "lazy-regex", "log", "reqwest 0.13.4", @@ -1227,6 +1228,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index d9c3924..b8193ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [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 @@ -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" diff --git a/migrations/.keep b/migrations/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/2026-07-28-124138-0000_create_post_versions/down.sql b/migrations/2026-07-28-124138-0000_create_post_versions/down.sql new file mode 100644 index 0000000..caaed5f --- /dev/null +++ b/migrations/2026-07-28-124138-0000_create_post_versions/down.sql @@ -0,0 +1 @@ +DROP TABLE post_versions; diff --git a/migrations/2026-07-28-124138-0000_create_post_versions/up.sql b/migrations/2026-07-28-124138-0000_create_post_versions/up.sql new file mode 100644 index 0000000..b107c77 --- /dev/null +++ b/migrations/2026-07-28-124138-0000_create_post_versions/up.sql @@ -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) +); \ No newline at end of file diff --git a/src/bin/disbahn.rs b/src/bin/disbahn.rs index f602485..db8513d 100644 --- a/src/bin/disbahn.rs +++ b/src/bin/disbahn.rs @@ -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 { #[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, }; diff --git a/src/database/models.rs b/src/database/models.rs index e742b81..547617c 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -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, + } + } +} diff --git a/src/database/schema.rs b/src/database/schema.rs index 531080c..1e3cf7d 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -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,); diff --git a/src/lib.rs b/src/lib.rs index 6335804..c40adba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,16 @@ pub mod database; use std::borrow::Cow; +use std::fmt::Write as _; -use crate::database::models::{NewPost, Post}; -use crate::database::schema::posts::dsl::posts; 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::{ @@ -17,6 +19,164 @@ use serenity::all::{ use serenity::http::Http; use serenity::model::webhook::Webhook; +struct Item<'a> { + guid: &'a str, + title: &'a str, + link: &'a str, + validity_begin: DateTime, + validity_end: DateTime, + icon: &'a str, + description: &'a str, + pub_datetime: DateTime, +} + +impl Item<'_> { + fn parse_validity_time(input: &str) -> anyhow::Result> { + 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!(r#"^.*?\s*"#i); + static RE_BOLD: &Lazy = regex!(r#"((.|\n)*?)"#i); + static RE_ITALIC: &Lazy = regex!(r#"((.|\n)*?)"#i); + static RE_STRIKETHROUGH: &Lazy = regex!(r#"((.|\n)*?)"#i); + static RE_NEWLINE: &Lazy = regex!(r#""#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 { + "HIM1" => "\u{1F6A7} Bauarbeiten".into(), + "HIM2" => "\u{26A0}\u{FE0F} Störung".into(), + "HIM3" => "\u{270A} Streik".into(), + other => format!("\u{2139}\u{FE0F} Information ({other})").into(), + } + } + + fn colour(&self) -> u32 { + match self.icon { + "HIM1" => 0xf5c211, + "HIM2" | "HIM3" => 0xc1121c, + _ => 0x154889, + } + } + + fn to_embed(&self, mut previous_versions: &[PostVersion]) -> anyhow::Result { + 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!("", self.validity_begin.timestamp()), + true, + ) + .field( + "Ende:", + format!("", 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, + "-# * {}", + 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 { + 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, @@ -40,97 +200,16 @@ impl DisbahnClient { Ok(channel) } - fn validity_time_to_timestamp(input: &str) -> anyhow::Result { - 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!(r#"^.*?\s*"#i); - static RE_BOLD: &Lazy = regex!(r#"((.|\n)*?)"#i); - static RE_ITALIC: &Lazy = regex!(r#"((.|\n)*?)"#i); - static RE_STRIKETHROUGH: &Lazy = regex!(r#"((.|\n)*?)"#i); - static RE_NEWLINE: &Lazy = regex!(r#""#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_tag(name: &str) -> Cow<'static, str> { - match name { - "HIM1" => "\u{1F6A7} Bauarbeiten".into(), - "HIM2" => "\u{26A0}\u{FE0F} Störung".into(), - "HIM3" => "\u{270A} Streik".into(), - other => format!("\u{2139}\u{FE0F} Information ({other})").into(), - } - } - - 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 { - 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 tag = Self::icon_name_to_tag(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) - .author(CreateEmbedAuthor::new(tag)) - .colour(Self::icon_name_to_colour(icon)) - .description(description) - .field("Beginn:", format!(""), true) - .field("Ende:", format!(""), true) - .timestamp(pub_datetime) - .footer(CreateEmbedFooter::new("zuginfo.nrw").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<()> { @@ -152,27 +231,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 = 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 = 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.desc()) + .load::(self.database.conn()) + .with_context(|| "Error loading previous posts from database")?; + + let embed = item.to_embed(&previous_posts)?; self.webhook .edit_message( &self.http, @@ -182,17 +265,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)) @@ -202,13 +287,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(()) }