Rust로 마이그레이션

This commit is contained in:
암냥 2026-01-09 09:29:48 +09:00
commit 55f1cdf1bf
No known key found for this signature in database
23 changed files with 3363 additions and 1339 deletions

View file

@ -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
View 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
View 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;
}
}
}
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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.";
}

View file

@ -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
View 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
View 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
View 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
View file

@ -1,5 +0,0 @@
declare module "*.toml" {
import { RssSource } from "./types";
const value: Record<string, RssSource>;
export default value;
}

View file

@ -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;
};
}