Compare commits

...
2 Commits
Author SHA1 Message Date
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 261 additions and 125 deletions
Generated
+10
View File
@@ -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"
+2 -1
View File
@@ -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"
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,);
+204 -117
View File
@@ -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<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 {
"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<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,
@@ -40,97 +200,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_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)
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<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.desc())
.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,
@@ -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(())
}
+6 -6
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,11 +15,11 @@ 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() {
if let Err(err) = dotenvy::dotenv()
&& !err.not_found()
{
return Err(err).context("Unable to load .env file");
}
}
env_logger::builder()
.filter_module(module_path!(), log::LevelFilter::Debug)
@@ -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,
};