add sqlite database and message editing support

This commit is contained in:
2023-11-11 15:44:05 +01:00
parent e892fb0c5b
commit e1adadc843
11 changed files with 501 additions and 344 deletions
Generated
+292 -258
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -10,15 +10,15 @@ rss = "2.0"
reqwest = {version = "0.11", default-features = false, features = ["rustls"]} reqwest = {version = "0.11", default-features = false, features = ["rustls"]}
tokio = {version = "1.28", features = ["rt-multi-thread", "signal"]} tokio = {version = "1.28", features = ["rt-multi-thread", "signal"]}
lazy-regex = "3.0.2" lazy-regex = "3.0.2"
#time = {version = "0.3", features = ["local-offset" ,"macros", "parsing"]}
chrono = "0.4.25" chrono = "0.4.25"
chrono-tz = "0.8.2" chrono-tz = "0.8.2"
anyhow = "1.0.71" anyhow = "1.0.71"
log = "0.4.18" log = "0.4.18"
env_logger = "0.10.0" env_logger = "0.10.0"
dotenvy = "0.15.7" dotenvy = "0.15.7"
sled = { version = "0.34.7", features = ["compression"] } diesel = { version = "2.1.3", features = ["sqlite", "chrono"] }
bincode = "1.3.3" diesel_migrations = "2.1.0"
getset = "0.1.2"
[dependencies.serenity] [dependencies.serenity]
version = "0.11" version = "0.11"
+9
View File
@@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/database/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId"]
[migrations_directory]
dir = "migrations"
View File
@@ -0,0 +1 @@
DROP TABLE posts;
@@ -0,0 +1,7 @@
CREATE TABLE posts (
announcement_id TEXT NOT NULL,
webhook_id UNSIGNED BIG INT NOT NULL,
message_id UNSIGNED BIG INT NOT NULL,
last_updated TIMESTAMP NOT NULL,
PRIMARY KEY (announcement_id, webhook_id)
);
+30
View File
@@ -0,0 +1,30 @@
use disbahn::database::Database;
use disbahn::DisbahnClient;
use serenity::http::Http;
use std::env;
use std::env::VarError;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv()?;
env_logger::builder()
.filter_module(module_path!(), log::LevelFilter::Debug)
.init();
let database_url = match env::var("DATABASE_URL") {
Ok(url) => url,
Err(VarError::NotPresent) => "disbahn.db".to_string(),
Err(err) => return Err(err.into()),
};
let webhook_url = env::var("WEBHOOK_URL")?;
let feed_url = env::var("FEED_URL")?;
let database = Database::connect(&database_url)?;
let http = Http::new("");
let webhook = http.get_webhook_from_url(&webhook_url).await.unwrap();
let mut disbahn_client = DisbahnClient::new(database, webhook, http, feed_url);
disbahn_client.refresh().await
}
+39
View File
@@ -0,0 +1,39 @@
pub mod models;
pub mod schema;
use anyhow::format_err;
use diesel::prelude::*;
use diesel::SqliteConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use log::debug;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
pub struct Database {
conn: SqliteConnection,
}
impl Database {
fn new(conn: SqliteConnection) -> Self {
Self { conn }
}
pub fn connect(url: &str) -> anyhow::Result<Self> {
let mut connection = SqliteConnection::establish(url)
.map_err(|err| format_err!("Unable to connect to database at {url}: {err}"))?;
debug!("Established connection to SQLite database at {url}");
let migration_versions = connection
.run_pending_migrations(MIGRATIONS)
.map_err(|err| format_err!("Unable to run database migrations: {err}"))?;
if !migration_versions.is_empty() {
debug!("Ran database migrations for versions {migration_versions:?}");
}
Ok(Self::new(connection))
}
pub fn conn(&mut self) -> &mut SqliteConnection {
&mut self.conn
}
}
+51
View File
@@ -0,0 +1,51 @@
use super::schema::*;
use chrono::NaiveDateTime;
use diesel::{Insertable, Queryable};
use getset::Getters;
use serenity::model::id::{MessageId, WebhookId};
use std::borrow::Cow;
#[derive(Queryable, Getters)]
pub struct Post {
#[getset(get = "pub")]
announcement_id: String,
webhook_id: i64,
message_id: i64,
#[getset(get = "pub")]
last_updated: NaiveDateTime,
}
impl Post {
pub fn webhook_id(&self) -> WebhookId {
WebhookId(u64::from_le_bytes(self.webhook_id.to_le_bytes()))
}
pub fn message_id(&self) -> MessageId {
MessageId(u64::from_le_bytes(self.message_id.to_le_bytes()))
}
}
#[derive(Insertable)]
#[diesel(table_name = posts)]
pub struct NewPost<'a> {
announcement_id: Cow<'a, str>,
webhook_id: i64,
message_id: i64,
last_updated: NaiveDateTime,
}
impl<'a> NewPost<'a> {
pub fn new(
announcement_id: impl Into<Cow<'a, str>>,
webhook_id: WebhookId,
message_id: MessageId,
last_updated: NaiveDateTime,
) -> Self {
Self {
announcement_id: announcement_id.into(),
webhook_id: i64::from_le_bytes(webhook_id.0.to_le_bytes()),
message_id: i64::from_le_bytes(message_id.0.to_le_bytes()),
last_updated,
}
}
}
+10
View File
@@ -0,0 +1,10 @@
// @generated automatically by Diesel CLI.
diesel::table! {
posts (announcement_id, webhook_id) {
announcement_id -> Text,
webhook_id -> BigInt,
message_id -> BigInt,
last_updated -> Timestamp,
}
}
+59 -83
View File
@@ -1,5 +1,11 @@
pub mod database;
use crate::database::models::{NewPost, Post};
use crate::database::schema::posts::dsl::posts;
use crate::database::Database;
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; use chrono::{DateTime, NaiveDateTime, TimeZone};
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl};
use lazy_regex::regex; use lazy_regex::regex;
use log::{debug, info}; use log::{debug, info};
use reqwest::IntoUrl; use reqwest::IntoUrl;
@@ -7,51 +13,21 @@ use serenity::http::Http;
use serenity::json::Value; use serenity::json::Value;
use serenity::model::channel::Embed; use serenity::model::channel::Embed;
use serenity::model::webhook::Webhook; use serenity::model::webhook::Webhook;
use std::borrow::Cow;
use std::env;
struct RefreshDb(sled::Db); pub struct DisbahnClient {
database: Database,
impl RefreshDb {
fn get(&self, guid: &str) -> anyhow::Result<Option<DateTime<Utc>>> {
self.0
.get(guid)
.with_context(|| "db error: unable to get value")?
.map(|b| {
bincode::deserialize::<i64>(&b)
.with_context(|| "db error: unable to deserialize i64")
})
.transpose()?
.map(|i| {
Utc.timestamp_opt(i, 0)
.single()
.with_context(|| "db error: unable to parse timestamp from i64")
})
.transpose()
}
fn insert(&self, guid: String, datetime: DateTime<Utc>) -> anyhow::Result<()> {
let timestamp = datetime.timestamp();
let bytes = bincode::serialize(&timestamp).with_context(|| "db error")?;
self.0.insert(guid, bytes).with_context(|| "db error")?;
Ok(())
}
}
struct DisbahnClient {
webhook: Webhook, webhook: Webhook,
http: Http, http: Http,
rss_url: String, rss_url: String,
known_guids: RefreshDb,
} }
impl DisbahnClient { impl DisbahnClient {
fn new(webhook: Webhook, http: Http, rss_url: String, known_guids: RefreshDb) -> Self { pub fn new(database: Database, webhook: Webhook, http: Http, rss_url: String) -> Self {
Self { Self {
database,
webhook, webhook,
http, http,
rss_url, rss_url,
known_guids,
} }
} }
@@ -93,7 +69,7 @@ impl DisbahnClient {
} }
} }
fn item_to_embed(item: &rss::Item, is_update: bool) -> anyhow::Result<Value> { fn item_to_embed(item: &rss::Item) -> anyhow::Result<Value> {
const COLOUR: u32 = 0x008d4f; const COLOUR: u32 = 0x008d4f;
const FOOTER_ICON_URL: &str = "https://www.zuginfo.nrw/img/customer/apple-touch-icon.png"; const FOOTER_ICON_URL: &str = "https://www.zuginfo.nrw/img/customer/apple-touch-icon.png";
@@ -135,12 +111,9 @@ impl DisbahnClient {
let pub_timestamp = pub_datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let pub_timestamp = pub_datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let embed = Embed::fake(|e| { let embed =
e.title(if is_update { Embed::fake(|e| {
Cow::Owned(format!("UPDATE: {title}")) e.title(title)
} else {
Cow::Borrowed(title)
})
.url(link) .url(link)
.thumbnail(icon_url) .thumbnail(icon_url)
.colour(COLOUR) .colour(COLOUR)
@@ -153,12 +126,14 @@ impl DisbahnClient {
f.text("Quelle: https://zuginfo.nrw/ \u{2013} Alle Angaben ohne Gewehr \u{1F52B}") f.text("Quelle: https://zuginfo.nrw/ \u{2013} Alle Angaben ohne Gewehr \u{1F52B}")
.icon_url(FOOTER_ICON_URL) .icon_url(FOOTER_ICON_URL)
}) })
}); });
Ok(embed) Ok(embed)
} }
async fn refresh(&mut self) -> anyhow::Result<()> { pub async fn refresh(&mut self) -> anyhow::Result<()> {
use crate::database::schema::posts::{self, dsl};
debug!("Refreshing RSS feed ..."); debug!("Refreshing RSS feed ...");
let channel = Self::get_rss_channel(&self.rss_url) let channel = Self::get_rss_channel(&self.rss_url)
.await .await
@@ -177,53 +152,54 @@ impl DisbahnClient {
.naive_utc() .naive_utc()
.and_utc(); .and_utc();
let is_update; let existing_post: Option<Post> = posts
if let Some(last_pub_datetime) = self.known_guids.get(guid)? { .filter(dsl::webhook_id.eq(i64::from_le_bytes(self.webhook.id.0.to_le_bytes())))
if last_pub_datetime < pub_datetime { .filter(dsl::announcement_id.eq(guid))
is_update = true; .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}"); info!("Updated item: {guid}");
} else { let embed = Self::item_to_embed(item)?;
continue; self.webhook
.edit_message(&self.http, existing_post.message_id(), |w| {
w.embeds(vec![embed])
})
.await
.with_context(|| "Failed to edit message")?;
diesel::update(
posts.find((guid, i64::from_le_bytes(self.webhook.id.0.to_le_bytes()))),
)
.set(dsl::last_updated.eq(pub_datetime.naive_utc()))
.execute(self.database.conn())
.with_context(|| "Error updating post in database")?;
} }
} else { } else {
is_update = false;
info!("New item: {guid}"); info!("New item: {guid}");
} let embed = Self::item_to_embed(item)?;
let message = self
.webhook
.execute(&self.http, true, |w| w.embeds(vec![embed]))
.await
.with_context(|| "Failed to send message")?
.with_context(|| "Discord did not return a message id")?;
let embed = Self::item_to_embed(item, is_update)?; diesel::insert_into(posts::table)
self.webhook .values(NewPost::new(
.execute(&self.http, false, |w| w.embeds(vec![embed])) guid,
.await self.webhook.id,
.with_context(|| "Failed to execute webhook")?; message.id,
self.known_guids pub_datetime.naive_utc(),
.insert(guid.to_string(), pub_datetime) ))
.with_context(|| "Failed to write to database")?; .execute(self.database.conn())
.with_context(|| "Error inserting new post into database")?;
}
} }
debug!("Done."); debug!("Done.");
Ok(()) Ok(())
} }
} }
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv()?;
env_logger::builder()
.filter_module(module_path!(), log::LevelFilter::Debug)
.init();
let webhook_url = env::var("WEBHOOK_URL")?;
let feed_url = env::var("FEED_URL")?;
let known_guids_db = env::var("KNOWN_GUIDS_DB")?;
let known_guids_db =
RefreshDb(sled::open(known_guids_db).with_context(|| "unable to open database")?);
let http = Http::new("");
let webhook = http.get_webhook_from_url(&webhook_url).await.unwrap();
let mut disbahn_client = DisbahnClient::new(webhook, http, feed_url, known_guids_db);
disbahn_client.refresh().await
}