mirror of
https://github.com/nelsbrock/disbahn.git
synced 2026-08-14 22:26:49 +02:00
add sqlite database and message editing support
This commit is contained in:
Generated
+292
-258
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-84
@@ -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(×tamp).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)
|
||||||
@@ -158,7 +131,9 @@ impl DisbahnClient {
|
|||||||
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())
|
||||||
info!("Updated item: {guid}");
|
.optional()
|
||||||
} else {
|
.with_context(|| "Error loading posts from database")?;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
is_update = false;
|
|
||||||
info!("New item: {guid}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let embed = Self::item_to_embed(item, is_update)?;
|
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)?;
|
||||||
self.webhook
|
self.webhook
|
||||||
.execute(&self.http, false, |w| w.embeds(vec![embed]))
|
.edit_message(&self.http, existing_post.message_id(), |w| {
|
||||||
|
w.embeds(vec![embed])
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.with_context(|| "Failed to execute webhook")?;
|
.with_context(|| "Failed to edit message")?;
|
||||||
self.known_guids
|
|
||||||
.insert(guid.to_string(), pub_datetime)
|
diesel::update(
|
||||||
.with_context(|| "Failed to write to database")?;
|
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 {
|
||||||
|
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")?;
|
||||||
|
|
||||||
|
diesel::insert_into(posts::table)
|
||||||
|
.values(NewPost::new(
|
||||||
|
guid,
|
||||||
|
self.webhook.id,
|
||||||
|
message.id,
|
||||||
|
pub_datetime.naive_utc(),
|
||||||
|
))
|
||||||
|
.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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user