Rust로 마이그레이션
This commit is contained in:
parent
830e9c7eb9
commit
55f1cdf1bf
23 changed files with 3363 additions and 1339 deletions
|
|
@ -1,79 +0,0 @@
|
|||
import {
|
||||
SlashCommandBuilder,
|
||||
PermissionFlagsBits,
|
||||
ChatInputCommandInteraction,
|
||||
ForumChannel,
|
||||
Client,
|
||||
} from "discord.js";
|
||||
|
||||
export const commands = [
|
||||
new SlashCommandBuilder()
|
||||
.setName("clear-forum")
|
||||
.setDescription("포럼 채널의 모든 게시물을 삭제합니다")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addChannelOption((option) =>
|
||||
option
|
||||
.setName("channel")
|
||||
.setDescription("삭제할 포럼 채널 (미지정 시 현재 채널)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.toJSON(),
|
||||
];
|
||||
|
||||
export async function handleClearForum(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
client: Client
|
||||
): Promise<void> {
|
||||
const targetChannel = interaction.options.getChannel("channel") || interaction.channel;
|
||||
|
||||
if (!targetChannel) {
|
||||
await interaction.reply({ content: "Channel is not found", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await client.channels.fetch(targetChannel.id);
|
||||
|
||||
if (!channel || !(channel instanceof ForumChannel)) {
|
||||
await interaction.reply({ content: "Channel is not a forum channel", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
const [activeThreads, archivedThreads] = await Promise.all([
|
||||
channel.threads.fetch(),
|
||||
channel.threads.fetchArchived(),
|
||||
]);
|
||||
|
||||
const allThreads = [
|
||||
...activeThreads.threads.values(),
|
||||
...archivedThreads.threads.values(),
|
||||
];
|
||||
|
||||
if (allThreads.length === 0) {
|
||||
await interaction.editReply("No posts to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const thread of allThreads) {
|
||||
try {
|
||||
await thread.delete();
|
||||
deleted++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete thread: ${thread.name}`, error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply(
|
||||
`Deleted: ${deleted}\nFailed: ${failed}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error occurred while deleting forum posts:", error);
|
||||
await interaction.editReply("❌ An error occurred while deleting posts.");
|
||||
}
|
||||
}
|
||||
26
src/config.rs
Normal file
26
src/config.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct RssConfig {
|
||||
pub channel: String,
|
||||
pub tag: String,
|
||||
pub emoji: String,
|
||||
pub rss: String,
|
||||
pub category_filter: Option<Vec<String>>,
|
||||
pub setup: Option<SetupConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct SetupConfig {
|
||||
pub title: Option<String>,
|
||||
pub link: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub author: Option<String>,
|
||||
#[serde(rename = "authorLink")]
|
||||
pub author_link: Option<String>,
|
||||
#[serde(rename = "pubDate")]
|
||||
pub pub_date: Option<String>,
|
||||
}
|
||||
|
||||
pub type FullConfig = HashMap<String, RssConfig>;
|
||||
60
src/discord.rs
Normal file
60
src/discord.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
use serenity::async_trait;
|
||||
use serenity::model::gateway::Ready;
|
||||
use serenity::model::application::Interaction;
|
||||
use serenity::prelude::*;
|
||||
use crate::config::FullConfig;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Handler {
|
||||
pub config: Arc<FullConfig>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for Handler {
|
||||
async fn ready(&self, ctx: Context, ready: Ready) {
|
||||
println!("{} is connected!", ready.user.name);
|
||||
|
||||
// Register slash commands
|
||||
let commands = vec![
|
||||
serenity::builder::CreateCommand::new("clear-forum")
|
||||
.description("Clear posts in the forum channel")
|
||||
];
|
||||
|
||||
for command in commands {
|
||||
if let Err(e) = serenity::model::application::Command::create_global_command(&ctx.http, command).await {
|
||||
eprintln!("Cannot create command: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
||||
if let Interaction::Command(command) = interaction {
|
||||
if command.data.name == "clear-forum" {
|
||||
let _ = command.defer(&ctx.http).await;
|
||||
|
||||
let mut count = 0;
|
||||
for rss_config in self.config.values() {
|
||||
if let Ok(channel_id_val) = rss_config.channel.parse::<u64>() {
|
||||
let channel_id = serenity::model::id::ChannelId::new(channel_id_val);
|
||||
if let Ok(serenity::all::Channel::Guild(guild_channel)) = channel_id.to_channel(&ctx.http).await {
|
||||
if let Ok(threads) = guild_channel.guild_id.get_active_threads(&ctx.http).await {
|
||||
for thread in threads.threads {
|
||||
if thread.parent_id == Some(channel_id) {
|
||||
if let Err(e) = thread.delete(&ctx.http).await {
|
||||
eprintln!("Failed to delete thread {}: {}", thread.id, e);
|
||||
} else {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = command.edit_response(&ctx.http, serenity::builder::EditInteractionResponse::new()
|
||||
.content(format!("✅ Successfully cleared {} threads from forum channels.", count))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/index.ts
55
src/index.ts
|
|
@ -1,55 +0,0 @@
|
|||
import { ActivityType, Client, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
import { token, INTERVAL_MS } from "./lib/config";
|
||||
import { commands, handleClearForum } from "./commands";
|
||||
import { checkRssFeeds } from "./lib/rss";
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds],
|
||||
});
|
||||
|
||||
async function getGitCommitHash(): Promise<string> {
|
||||
try {
|
||||
const data = await Bun.file("./version").text();
|
||||
return data.trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
client.once("clientReady", async () => {
|
||||
console.log(`Logged in as: ${client.user?.tag}`);
|
||||
|
||||
// Git commit hash로 상태 설정
|
||||
const commitHash = await getGitCommitHash();
|
||||
client.user?.setActivity({
|
||||
name: `${commitHash}`,
|
||||
type: ActivityType.Playing,
|
||||
});
|
||||
console.log(`Version: ${commitHash}`);
|
||||
|
||||
// Register slash commands
|
||||
try {
|
||||
const rest = new REST().setToken(token!);
|
||||
await rest.put(Routes.applicationCommands(client.user!.id), { body: commands });
|
||||
console.log("✅ Slash commands registered successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to register slash commands:", error);
|
||||
}
|
||||
|
||||
// Start checking RSS feeds
|
||||
checkRssFeeds(client);
|
||||
setInterval(() => checkRssFeeds(client), INTERVAL_MS);
|
||||
console.log(`⏰ Checking RSS feeds every ${process.env.INTERVAL_MINUTES ? process.env.INTERVAL_MINUTES : 5} minutes`);
|
||||
});
|
||||
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
switch (interaction.commandName) {
|
||||
case "clear-forum":
|
||||
await handleClearForum(interaction, client);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
client.login(token);
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import Parser from "rss-parser";
|
||||
|
||||
export const PROCESSED_FILE = "./processed.json";
|
||||
export const INTERVAL_MS =
|
||||
process.env.INTERVAL_MINUTES
|
||||
? parseInt(process.env.INTERVAL_MINUTES) * 60 * 1000
|
||||
: 5 * 60 * 1000;
|
||||
|
||||
export const parser = new Parser({
|
||||
headers: {
|
||||
"User-Agent": process.env.USER_AGENT || "NekoRSS/1.0 (+abuse@imnya.ng)",
|
||||
},
|
||||
});
|
||||
|
||||
export const token = process.env.DISCORD_TOKEN;
|
||||
|
||||
if (!token) {
|
||||
console.error("Discord Token is not set in environment variables.");
|
||||
process.exit(1);
|
||||
}
|
||||
220
src/lib/rss.ts
220
src/lib/rss.ts
|
|
@ -1,220 +0,0 @@
|
|||
import { ForumChannel, ThreadAutoArchiveDuration, Client } from "discord.js";
|
||||
import type Parser from "rss-parser";
|
||||
import TurndownService from "turndown";
|
||||
import { parser } from "./config";
|
||||
import { processedItems, saveCurrentState } from "./storage";
|
||||
import type { RssSource } from "../type/types";
|
||||
|
||||
export async function checkRssFeeds(client: Client): Promise<void> {
|
||||
console.log(`[${new Date().toISOString()}] Checking RSS feeds...`);
|
||||
|
||||
let rssConfig: Record<string, RssSource>;
|
||||
try {
|
||||
const configFile = Bun.file("./rss.toml");
|
||||
const content = await configFile.text();
|
||||
rssConfig = Bun.TOML.parse(content) as Record<string, RssSource>;
|
||||
} catch (error) {
|
||||
console.error("Failed to load rss.toml:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, config] of Object.entries(rssConfig)) {
|
||||
try {
|
||||
await processRssFeed(name, config, client);
|
||||
} catch (error) {
|
||||
console.error(`[${name}] Failed to check RSS feed:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getValueByPath(obj: any, path: string | undefined): any {
|
||||
if (!path) return undefined;
|
||||
if (obj[path] !== undefined) return obj[path];
|
||||
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
|
||||
}
|
||||
|
||||
function getFieldValue(
|
||||
item: Parser.Item,
|
||||
config: RssSource,
|
||||
field: keyof NonNullable<RssSource["setup"]>
|
||||
): any {
|
||||
const path = config.setup?.[field];
|
||||
const itemany = item as any;
|
||||
if (path) {
|
||||
return getValueByPath(itemany, path);
|
||||
}
|
||||
|
||||
switch (field) {
|
||||
case "title":
|
||||
return item.title;
|
||||
case "link":
|
||||
return item.link;
|
||||
case "content":
|
||||
return item.content || item.contentSnippet;
|
||||
case "pubDate":
|
||||
return item.pubDate;
|
||||
case "author":
|
||||
return (
|
||||
itemany.creator ||
|
||||
(typeof itemany.author === "string"
|
||||
? itemany.author
|
||||
: itemany.author?.name)
|
||||
);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function processRssFeed(
|
||||
name: string,
|
||||
config: RssSource,
|
||||
client: Client
|
||||
): Promise<void> {
|
||||
const feed = await parser.parseURL(config.rss);
|
||||
|
||||
if (!processedItems.has(name)) {
|
||||
processedItems.set(name, new Set());
|
||||
}
|
||||
|
||||
const processed = processedItems.get(name)!;
|
||||
|
||||
// 현재 RSS 피드에 있는 항목들의 ID 수집
|
||||
const currentFeedIds = new Set(
|
||||
feed.items
|
||||
.map((item) => getFieldValue(item, config, "link") || item.guid || "")
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
// RSS 피드에서 사라진 항목들을 처리된 목록에서 제거
|
||||
const removedItems: string[] = [];
|
||||
for (const itemId of processed) {
|
||||
if (!currentFeedIds.has(itemId)) {
|
||||
removedItems.push(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (removedItems.length > 0) {
|
||||
for (const itemId of removedItems) {
|
||||
processed.delete(itemId);
|
||||
}
|
||||
console.log(
|
||||
`[${name}] Cleaned up ${removedItems.length} old items from processed list`
|
||||
);
|
||||
saveCurrentState();
|
||||
}
|
||||
|
||||
for (const item of feed.items) {
|
||||
const itemId = getFieldValue(item, config, "link") || item.guid || "";
|
||||
|
||||
if (!itemId || processed.has(itemId)) continue;
|
||||
|
||||
// 카테고리 필터링
|
||||
if (shouldFilterByCategory(config, item)) {
|
||||
const title = getFieldValue(item, config, "title") || "Untitled";
|
||||
console.log(
|
||||
`[${name}] Filtered by category: ${title} (${item.categories?.join(
|
||||
", "
|
||||
)})`
|
||||
);
|
||||
processed.add(itemId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = getFieldValue(item, config, "title") || "Untitled";
|
||||
console.log(`[${name}] New item found: ${title}`);
|
||||
|
||||
await postToForum(config, item, client);
|
||||
processed.add(itemId);
|
||||
saveCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
function shouldFilterByCategory(config: RssSource, item: Parser.Item): boolean {
|
||||
if (!config.category_filter?.length) return false;
|
||||
|
||||
const categories = item.categories || [];
|
||||
return categories.some((cat) => config.category_filter!.includes(cat));
|
||||
}
|
||||
|
||||
async function postToForum(
|
||||
config: RssSource,
|
||||
item: Parser.Item,
|
||||
client: Client
|
||||
): Promise<void> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const channel = await client.channels.fetch(config.channel);
|
||||
|
||||
if (!channel || !(channel instanceof ForumChannel)) {
|
||||
console.error(
|
||||
`Channel ${config.channel} not found or is not a forum channel.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (getFieldValue(item, config, "title") || "Untitled").slice(
|
||||
0,
|
||||
100
|
||||
);
|
||||
const content = buildContent(config, item);
|
||||
|
||||
await channel.threads.create({
|
||||
name: title,
|
||||
autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
|
||||
message: { content },
|
||||
appliedTags: [config.tag],
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
console.log(
|
||||
`[Forum] Post completed: ${title} (${(endTime - startTime).toFixed(2)}ms)`
|
||||
);
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
console.error(
|
||||
`[Forum] Post failed (${(endTime - startTime).toFixed(2)}ms):`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildContent(config: RssSource, item: Parser.Item): string {
|
||||
const parts: string[] = [];
|
||||
const turndownService = new TurndownService();
|
||||
|
||||
const title = getFieldValue(item, config, "title") || "Untitled";
|
||||
const link = getFieldValue(item, config, "link");
|
||||
const content = getFieldValue(item, config, "content");
|
||||
const author = getFieldValue(item, config, "author");
|
||||
const authorLink = getFieldValue(item, config, "authorLink");
|
||||
const pubDate = getFieldValue(item, config, "pubDate");
|
||||
|
||||
if (link) {
|
||||
parts.push(`# ${config.emoji} | [${title}](<${link}>)`);
|
||||
} else {
|
||||
parts.push(`# ${config.emoji} | ${title}`);
|
||||
}
|
||||
|
||||
if (author) {
|
||||
if (authorLink) {
|
||||
parts.push(`-# 🖊️ [${author}](<${authorLink}>)`);
|
||||
} else {
|
||||
parts.push(`-# 🖊️ ${author}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const markdown = turndownService.turndown(content);
|
||||
parts.push(`\n${markdown.trim()}\n`);
|
||||
}
|
||||
|
||||
if (pubDate) {
|
||||
const timestamp = Math.floor(new Date(pubDate).getTime() / 1000);
|
||||
if (!isNaN(timestamp)) {
|
||||
parts.push(`\n-# 🕐 <t:${timestamp}:f>`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n") || link || "No content available.";
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { readFileSync, writeFileSync, existsSync, statSync, rmSync } from "fs";
|
||||
import { PROCESSED_FILE } from "./config";
|
||||
|
||||
export function loadProcessedItems(): Record<string, string[]> {
|
||||
if (existsSync(PROCESSED_FILE)) {
|
||||
try {
|
||||
// 디렉토리인 경우 삭제
|
||||
const stat = statSync(PROCESSED_FILE);
|
||||
if (stat.isDirectory()) {
|
||||
console.warn(`${PROCESSED_FILE}이(가) 디렉토리입니다. 삭제 후 새로 생성합니다.`);
|
||||
rmSync(PROCESSED_FILE, { recursive: true });
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(readFileSync(PROCESSED_FILE, "utf-8"));
|
||||
} catch (error) {
|
||||
console.error("처리된 항목 파일 읽기 실패:", error);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function saveProcessedItems(items: Record<string, string[]>): void {
|
||||
try {
|
||||
writeFileSync(PROCESSED_FILE, JSON.stringify(items, null, 2));
|
||||
} catch (error) {
|
||||
console.error("처리된 항목 파일 저장 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 처리된 항목 Map
|
||||
const processedData = loadProcessedItems();
|
||||
export const processedItems = new Map<string, Set<string>>(
|
||||
Object.entries(processedData).map(([k, v]) => [k, new Set(v)])
|
||||
);
|
||||
|
||||
export function saveCurrentState(): void {
|
||||
saveProcessedItems(
|
||||
Object.fromEntries(
|
||||
Array.from(processedItems.entries()).map(([k, v]) => [k, Array.from(v)])
|
||||
)
|
||||
);
|
||||
}
|
||||
141
src/main.rs
Normal file
141
src/main.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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("processed_items.db")?);
|
||||
|
||||
// Migration from old processed.json if it exists
|
||||
let old_processed_path = "../memos-rss/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 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?;
|
||||
|
||||
// items 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.items().to_vec();
|
||||
items.reverse();
|
||||
|
||||
for item in items {
|
||||
let item_id = rss::get_field_value(&item, config, "link")
|
||||
.or_else(|| item.guid().map(|g| g.value().to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
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.name()).collect();
|
||||
if item_categories.iter().any(|c| filters.contains(&c.to_string())) {
|
||||
println!("[{}] Filtered by category: {:?}", name, item_categories);
|
||||
storage.mark_processed(name, &item_id)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
println!("[{}] New item: {:?}", name, item.title());
|
||||
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>()?;
|
||||
|
||||
// Post to forum
|
||||
let post = serenity::builder::CreateForumPost::new(
|
||||
title,
|
||||
serenity::builder::CreateMessage::new().content(content)
|
||||
).add_applied_tag(serenity::model::id::ForumTagId::new(tag_id));
|
||||
|
||||
if let Err(e) = channel.create_forum_post(&http, post).await {
|
||||
eprintln!("[{}] Failed to create forum post: {}", name, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// After creating thread, post the message if needed,
|
||||
// but CreateThread in Serenity for Forum usually takes message too?
|
||||
// Actually, for Forum channels, the first message is part of the thread creation.
|
||||
// Let's refine this if needed based on Serenity 0.12 API.
|
||||
|
||||
storage.mark_processed(name, &item_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
119
src/rss.rs
Normal file
119
src/rss.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use crate::config::RssConfig;
|
||||
use anyhow::Result;
|
||||
use htmd::HtmlToMarkdown;
|
||||
use rss::Channel;
|
||||
|
||||
pub async fn fetch_feed(url: &str) -> Result<Channel> {
|
||||
let content = reqwest::get(url).await?.bytes().await?;
|
||||
let channel = Channel::read_from(&content[..])?;
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
pub fn get_field_value(
|
||||
item: &rss::Item,
|
||||
config: &RssConfig,
|
||||
field: &str,
|
||||
) -> Option<String> {
|
||||
let setup = config.setup.as_ref();
|
||||
let path = match field {
|
||||
"title" => setup.and_then(|s| s.title.as_ref()),
|
||||
"link" => setup.and_then(|s| s.link.as_ref()),
|
||||
"content" => setup.and_then(|s| s.content.as_ref()),
|
||||
"author" => setup.and_then(|s| s.author.as_ref()),
|
||||
"authorLink" => setup.and_then(|s| s.author_link.as_ref()),
|
||||
"pubDate" => setup.and_then(|s| s.pub_date.as_ref()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(p) = path {
|
||||
// Simple JSON path-like resolution for extensions if needed
|
||||
// For now, check standard fields first, then extensions
|
||||
resolve_path(item, p)
|
||||
} else {
|
||||
match field {
|
||||
"title" => item.title().map(|s| s.to_string()),
|
||||
"link" => item.link().map(|s| s.to_string()),
|
||||
"content" => item.content().or(item.description()).map(|s| s.to_string()),
|
||||
"author" => item.author().map(|s| s.to_string()),
|
||||
"pubDate" => item.pub_date().map(|s| s.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(item: &rss::Item, path: &str) -> Option<String> {
|
||||
// This is a simplified version. The TS version used a more complex object traversal.
|
||||
// rss-rs doesn't expose a raw JSON-like structure easily for all extensions.
|
||||
// We'll handle common cases or use extensions() if needed.
|
||||
|
||||
match path {
|
||||
"title" => item.title().map(|s| s.to_string()),
|
||||
"link" => item.link().map(|s| s.to_string()),
|
||||
"description" => item.description().map(|s| s.to_string()),
|
||||
"content" => item.content().map(|s| s.to_string()),
|
||||
"pubDate" => item.pub_date().map(|s| s.to_string()),
|
||||
"dc:creator" => item.dublin_core_ext()
|
||||
.and_then(|dc| dc.creators().first())
|
||||
.map(|s| s.to_string()),
|
||||
_ => {
|
||||
// Check extensions
|
||||
for (ns, ext) in item.extensions() {
|
||||
if path.starts_with(ns) {
|
||||
let local_name = &path[ns.len()+1..];
|
||||
if let Some(v) = ext.get(local_name) {
|
||||
if let Some(first) = v.first() {
|
||||
return first.value().map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_content(config: &RssConfig, item: &rss::Item) -> String {
|
||||
let mut parts = Vec::new();
|
||||
let ht = HtmlToMarkdown::new();
|
||||
|
||||
let title = get_field_value(item, config, "title").unwrap_or_else(|| "Untitled".to_string());
|
||||
let link = get_field_value(item, config, "link");
|
||||
let content = get_field_value(item, config, "content");
|
||||
let author = get_field_value(item, config, "author");
|
||||
let author_link = get_field_value(item, config, "authorLink");
|
||||
let pub_date = get_field_value(item, config, "pubDate");
|
||||
|
||||
if let Some(l) = link.as_ref() {
|
||||
parts.push(format!("# {} | [{}](<{}>)", config.emoji, title, l));
|
||||
} else {
|
||||
parts.push(format!("# {} | {}", config.emoji, title));
|
||||
}
|
||||
|
||||
if let Some(a) = author {
|
||||
if let Some(al) = author_link {
|
||||
parts.push(format!("-# 🖊️ [{}](<{}>)", a, al));
|
||||
} else {
|
||||
parts.push(format!("-# 🖊️ {}", a));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(c) = content {
|
||||
let markdown = ht.convert(&c).unwrap_or_else(|_| c);
|
||||
parts.push(format!("\n{}\n", markdown.trim()));
|
||||
}
|
||||
|
||||
if let Some(pd) = pub_date {
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(&pd) {
|
||||
parts.push(format!("\n-# 🕐 <t:{}:f>", dt.timestamp()));
|
||||
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&pd) {
|
||||
parts.push(format!("\n-# 🕐 <t:{}:f>", dt.timestamp()));
|
||||
}
|
||||
}
|
||||
|
||||
let joined = parts.join("\n");
|
||||
if joined.is_empty() {
|
||||
link.unwrap_or_else(|| "No content available.".to_string())
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
}
|
||||
56
src/storage.rs
Normal file
56
src/storage.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use anyhow::Result;
|
||||
use sled::Db;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct Storage {
|
||||
db: Db,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new(path: &str) -> Result<Self> {
|
||||
let db = sled::open(path)?;
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
pub fn is_processed(&self, feed_name: &str, item_id: &str) -> Result<bool> {
|
||||
let key = format!("{}:{}", feed_name, item_id);
|
||||
Ok(self.db.get(key)?.is_some())
|
||||
}
|
||||
|
||||
pub fn mark_processed(&self, feed_name: &str, item_id: &str) -> Result<()> {
|
||||
let key = format!("{}:{}", feed_name, item_id);
|
||||
self.db.insert(key, b"")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_processed_bulk(&self, data: std::collections::HashMap<String, Vec<String>>) -> Result<()> {
|
||||
let mut batch = sled::Batch::default();
|
||||
for (feed_name, ids) in data {
|
||||
for id in ids {
|
||||
let key = format!("{}:{}", feed_name, id);
|
||||
batch.insert(key.as_bytes(), b"");
|
||||
}
|
||||
}
|
||||
self.db.apply_batch(batch)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_processed_ids(&self, feed_name: &str) -> Result<HashSet<String>> {
|
||||
let prefix = format!("{}:", feed_name);
|
||||
let mut ids = HashSet::new();
|
||||
for item in self.db.scan_prefix(prefix.as_bytes()) {
|
||||
let (key, _) = item?;
|
||||
let key_str = String::from_utf8(key.to_vec())?;
|
||||
if let Some(id) = key_str.strip_prefix(&prefix) {
|
||||
ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub fn remove_processed(&self, feed_name: &str, item_id: &str) -> Result<()> {
|
||||
let key = format!("{}:{}", feed_name, item_id);
|
||||
self.db.remove(key)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
5
src/type/toml.d.ts
vendored
5
src/type/toml.d.ts
vendored
|
|
@ -1,5 +0,0 @@
|
|||
declare module "*.toml" {
|
||||
import { RssSource } from "./types";
|
||||
const value: Record<string, RssSource>;
|
||||
export default value;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
export interface RssSource {
|
||||
channel: string;
|
||||
tag: string;
|
||||
emoji: string;
|
||||
rss: string;
|
||||
category_filter?: string[];
|
||||
setup?: {
|
||||
title?: string;
|
||||
link?: string;
|
||||
content?: string;
|
||||
author?: string;
|
||||
authorLink?: string;
|
||||
pubDate?: string;
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue