Compare commits

..
4 Commits
Author SHA1 Message Date
nelsbrock d1842d8d92 improve category tags 2026-07-31 23:39:33 +02:00
nelsbrock ec0840bf53 fix ordering of previous titles list 2026-07-31 22:14:22 +02:00
nelsbrock 4256aaad1d move src/bin/disbahn.rs to src/main.rs 2026-07-28 16:51:23 +02:00
nelsbrock 3e31f4d1f9 add list for previous titles 2026-07-28 16:50:16 +02:00
9 changed files with 264 additions and 125 deletions
Generated
+10
View File
@@ -632,6 +632,7 @@ dependencies = [
"dotenvy", "dotenvy",
"env_logger", "env_logger",
"getset", "getset",
"itertools",
"lazy-regex", "lazy-regex",
"log", "log",
"reqwest 0.13.4", "reqwest 0.13.4",
@@ -1227,6 +1228,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
+2 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "disbahn" name = "disbahn"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # 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 = { version = "2.2.12", features = ["sqlite", "chrono"] }
diesel_migrations = "2.2.0" diesel_migrations = "2.2.0"
getset = "0.1.6" getset = "0.1.6"
itertools = "0.15.0"
[dependencies.serenity] [dependencies.serenity]
version = "0.12.4" 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. // @generated automatically by Diesel CLI.
diesel::table! {
post_versions (announcement_id, publish_date, title) {
announcement_id -> Text,
publish_date -> Timestamp,
title -> Text,
}
}
diesel::table! { diesel::table! {
posts (announcement_id, webhook_id) { posts (announcement_id, webhook_id) {
announcement_id -> Text, announcement_id -> Text,
@@ -8,3 +16,5 @@ diesel::table! {
last_updated -> Timestamp, last_updated -> Timestamp,
} }
} }
diesel::allow_tables_to_appear_in_same_query!(post_versions, posts,);
+207 -117
View File
@@ -1,14 +1,16 @@
pub mod database; pub mod database;
use std::borrow::Cow; 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 crate::database::Database;
use anyhow::{anyhow, Context}; use crate::database::models::{NewPost, Post, PostVersion};
use chrono::{DateTime, NaiveDateTime, TimeZone}; use crate::database::schema::post_versions;
use anyhow::{Context, Ok, anyhow};
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl}; 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 log::{debug, error, info};
use reqwest::IntoUrl; use reqwest::IntoUrl;
use serenity::all::{ use serenity::all::{
@@ -17,6 +19,167 @@ use serenity::all::{
use serenity::http::Http; use serenity::http::Http;
use serenity::model::webhook::Webhook; 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 { pub struct DisbahnClient {
database: Database, database: Database,
webhook: Webhook, webhook: Webhook,
@@ -40,97 +203,16 @@ impl DisbahnClient {
Ok(channel) Ok(channel)
} }
fn validity_time_to_timestamp(input: &str) -> anyhow::Result<i64> { async fn add_post_version_entry(&mut self, item: &Item<'_>) -> anyhow::Result<()> {
let naive = NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M:%S")?; diesel::insert_into(post_versions::table)
let timestamp = chrono_tz::Europe::Berlin .values(PostVersion::new(
.from_local_datetime(&naive) item.guid.to_string(),
.unwrap() item.pub_datetime.naive_utc(),
.timestamp(); item.title.to_string(),
Ok(timestamp) ))
} .execute(self.database.conn())
.with_context(|| "Error inserting new post version into database")?;
fn html_to_discord_markdown(input: &str) -> String { Ok(())
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_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<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 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!("<t:{validity_begin}:f>"), true)
.field("Ende:", format!("<t:{validity_end}:f>"), true)
.timestamp(pub_datetime)
.footer(CreateEmbedFooter::new("zuginfo.nrw").icon_url(FOOTER_ICON_URL));
Ok(embed)
} }
pub async fn refresh(&mut self) -> anyhow::Result<()> { pub async fn refresh(&mut self) -> anyhow::Result<()> {
@@ -152,27 +234,31 @@ impl DisbahnClient {
Ok(()) Ok(())
} }
async fn refresh_item(&mut self, item: &rss::Item) -> anyhow::Result<()> { async fn refresh_item(&mut self, rss_item: &rss::Item) -> anyhow::Result<()> {
use crate::database::schema::posts::{self, dsl}; use crate::database::schema::{post_versions, posts};
let guid = item.guid().ok_or(anyhow!("Missing GUID"))?.value(); let item: Item = rss_item.try_into()?;
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 existing_post: Option<Post> = posts let existing_post: Option<Post> = posts::dsl::posts
.filter(dsl::webhook_id.eq(i64::from_le_bytes(self.webhook.id.get().to_le_bytes()))) .filter(
.filter(dsl::announcement_id.eq(guid)) 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()) .first(self.database.conn())
.optional() .optional()
.with_context(|| "Error loading posts from database")?; .with_context(|| "Error loading posts from database")?;
if let Some(existing_post) = existing_post { if let Some(existing_post) = existing_post {
if existing_post.last_updated().and_utc() < pub_datetime { if existing_post.last_updated().and_utc() < item.pub_datetime {
info!("Updated item: {guid}"); info!("Updated item with id {}", item.guid);
let embed = Self::item_to_embed(item)?;
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 self.webhook
.edit_message( .edit_message(
&self.http, &self.http,
@@ -182,17 +268,19 @@ impl DisbahnClient {
.await .await
.with_context(|| "Failed to edit message")?; .with_context(|| "Failed to edit message")?;
diesel::update(posts.find(( diesel::update(posts::dsl::posts.find((
guid, item.guid,
i64::from_le_bytes(self.webhook.id.get().to_le_bytes()), 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()) .execute(self.database.conn())
.with_context(|| "Error updating post in database")?; .with_context(|| "Error updating post in database")?;
self.add_post_version_entry(&item).await?;
} }
} else { } else {
info!("New item: {guid}"); info!("New item with id {}", item.guid);
let embed = Self::item_to_embed(item)?; let embed = item.to_embed(&[])?;
let message = self let message = self
.webhook .webhook
.execute(&self.http, true, ExecuteWebhook::new().embed(embed)) .execute(&self.http, true, ExecuteWebhook::new().embed(embed))
@@ -202,13 +290,15 @@ impl DisbahnClient {
diesel::insert_into(posts::table) diesel::insert_into(posts::table)
.values(NewPost::new( .values(NewPost::new(
guid, item.guid,
self.webhook.id, self.webhook.id,
message.id, message.id,
pub_datetime.naive_utc(), item.pub_datetime.naive_utc(),
)) ))
.execute(self.database.conn()) .execute(self.database.conn())
.with_context(|| "Error inserting new post into database")?; .with_context(|| "Error inserting new post into database")?;
self.add_post_version_entry(&item).await?;
} }
Ok(()) Ok(())
} }
+7 -7
View File
@@ -1,6 +1,6 @@
use anyhow::{anyhow, Context}; use anyhow::{Context, anyhow};
use disbahn::database::Database;
use disbahn::DisbahnClient; use disbahn::DisbahnClient;
use disbahn::database::Database;
use log::error; use log::error;
use serenity::http::Http; use serenity::http::Http;
use std::env; use std::env;
@@ -15,10 +15,10 @@ fn env_var(name: &str) -> anyhow::Result<String> {
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
if let Err(err) = dotenvy::dotenv() { if let Err(err) = dotenvy::dotenv()
if !err.not_found() { && !err.not_found()
return Err(err).context("Unable to load .env file"); {
} return Err(err).context("Unable to load .env file");
} }
env_logger::builder() env_logger::builder()
@@ -31,7 +31,7 @@ async fn main() -> anyhow::Result<()> {
Some(s) => { Some(s) => {
return Err(anyhow!(format!( return Err(anyhow!(format!(
"invalid argument `{s}`; the only allowed argument is `daemon`" "invalid argument `{s}`; the only allowed argument is `daemon`"
))) )));
} }
None => false, None => false,
}; };