167 lines
5.8 KiB
Rust
167 lines
5.8 KiB
Rust
mod config;
|
|
mod rss;
|
|
mod storage;
|
|
mod discord;
|
|
|
|
use anyhow::Result;
|
|
use dotenvy::dotenv;
|
|
use serenity::prelude::*;
|
|
use std::env;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::time;
|
|
|
|
use crate::config::FullConfig;
|
|
use crate::storage::Storage;
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
dotenv().ok();
|
|
|
|
let token = env::var("DISCORD_TOKEN").expect("Expected DISCORD_TOKEN in environment");
|
|
let interval_mins = env::var("INTERVAL_MINUTES")
|
|
.unwrap_or_else(|_| "5".to_string())
|
|
.parse::<u64>()
|
|
.unwrap_or(5);
|
|
|
|
let rss_toml_content = std::fs::read_to_string("rss.toml")?;
|
|
let config: FullConfig = toml::from_str(&rss_toml_content)?;
|
|
let config = Arc::new(config);
|
|
|
|
let storage = Arc::new(Storage::new("./data/processed_items.db")?);
|
|
|
|
// Migration from old processed.json if it exists
|
|
let old_processed_path = "./data/processed.json";
|
|
if std::path::Path::new(old_processed_path).exists() {
|
|
println!("Found old processed.json, migrating data...");
|
|
let content = std::fs::read_to_string(old_processed_path)?;
|
|
if let Ok(data) = serde_json::from_str::<std::collections::HashMap<String, Vec<String>>>(&content) {
|
|
storage.mark_processed_bulk(data)?;
|
|
println!("Migration complete. Deleting old processed.json...");
|
|
// Optionally rename or delete it to avoid re-migration
|
|
let _ = std::fs::rename(old_processed_path, format!("{}.bak", old_processed_path));
|
|
}
|
|
}
|
|
|
|
let handler = discord::Handler {
|
|
config: config.clone(),
|
|
};
|
|
|
|
let mut client = Client::builder(&token, GatewayIntents::GUILDS)
|
|
.event_handler(handler)
|
|
.await
|
|
.expect("Err creating client");
|
|
|
|
let http = client.http.clone();
|
|
let config_clone = config.clone();
|
|
let storage_clone = storage.clone();
|
|
|
|
// Spawn RSS checking loop
|
|
tokio::spawn(async move {
|
|
let mut interval = time::interval(Duration::from_secs(interval_mins * 60));
|
|
loop {
|
|
interval.tick().await;
|
|
println!("Checking RSS feeds...");
|
|
for (name, rss_config) in config_clone.iter() {
|
|
if let Err(e) = check_feed(name, rss_config, &http, &storage_clone).await {
|
|
eprintln!("[{}] Error checking feed: {}", name, e);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if let Err(why) = client.start().await {
|
|
eprintln!("Client error: {:?}", why);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn download_image(url: &str) -> Result<Vec<u8>> {
|
|
let client = reqwest::Client::builder()
|
|
.user_agent(std::env::var("RSS_USER_AGENT").unwrap_or_else(|_| "NekoRSS/1.0".to_string()))
|
|
.build()?;
|
|
|
|
let response = client.get(url).send().await?;
|
|
let data = response.bytes().await?.to_vec();
|
|
Ok(data)
|
|
}
|
|
|
|
async fn check_feed(
|
|
name: &str,
|
|
config: &crate::config::RssConfig,
|
|
http: &Arc<serenity::http::Http>,
|
|
storage: &Arc<Storage>,
|
|
) -> Result<()> {
|
|
let feed = rss::fetch_feed(&config.rss).await?;
|
|
|
|
// entries usually come in descending order (newest first)
|
|
// we should probably reverse them to process oldest first to maintain order in Discord
|
|
let mut items = feed.entries;
|
|
items.reverse();
|
|
|
|
for item in items {
|
|
let item_id = rss::get_field_value(&item, config, "link")
|
|
.unwrap_or_else(|| item.id.clone());
|
|
|
|
if item_id.is_empty() { continue; }
|
|
|
|
if !storage.is_processed(name, &item_id)? {
|
|
// Category check
|
|
if let Some(filters) = &config.category_filter {
|
|
let item_categories: Vec<_> = item.categories.iter().map(|c| c.term.clone()).collect();
|
|
if item_categories.iter().any(|c| filters.contains(c)) {
|
|
println!("[{}] Filtered by category: {:?}", name, item_categories);
|
|
storage.mark_processed(name, &item_id)?;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
println!("[{}] New item: {:?}", name, item.title.as_ref().map(|t| &t.content));
|
|
let content = rss::build_content(config, &item);
|
|
|
|
let channel_id = config.channel.parse::<u64>()?;
|
|
let channel = serenity::model::id::ChannelId::new(channel_id);
|
|
|
|
let title = rss::get_field_value(&item, config, "title").unwrap_or_else(|| "Untitled".to_string());
|
|
let tag_id = config.tag.parse::<u64>()?;
|
|
|
|
// Extract image if needed
|
|
let image_url = if config.upload_image {
|
|
rss::extract_image_url(&item)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Create message with image if available
|
|
let mut message_builder = serenity::builder::CreateMessage::new().content(content);
|
|
if let Some(img_url) = image_url {
|
|
match download_image(&img_url).await {
|
|
Ok(image_data) => {
|
|
message_builder = message_builder.add_file(
|
|
serenity::all::CreateAttachment::bytes(image_data, "image.jpg")
|
|
);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("[{}] Failed to download image: {}", name, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Post to forum
|
|
let post = serenity::builder::CreateForumPost::new(
|
|
title,
|
|
message_builder
|
|
).add_applied_tag(serenity::all::ForumTagId::new(tag_id));
|
|
|
|
if let Err(e) = channel.create_forum_post(&http, post).await {
|
|
eprintln!("[{}] Failed to create forum post: {}", name, e);
|
|
continue;
|
|
}
|
|
|
|
storage.mark_processed(name, &item_id)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|