init
This commit is contained in:
commit
e176b1c094
17 changed files with 632 additions and 0 deletions
79
src/commands.ts
Normal file
79
src/commands.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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.");
|
||||
}
|
||||
}
|
||||
38
src/index.ts
Normal file
38
src/index.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { 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],
|
||||
});
|
||||
|
||||
client.once("clientReady", async () => {
|
||||
console.log(`Logged in as: ${client.user?.tag}`);
|
||||
|
||||
// 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);
|
||||
20
src/lib/config.ts
Normal file
20
src/lib/config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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);
|
||||
}
|
||||
133
src/lib/rss.ts
Normal file
133
src/lib/rss.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { ForumChannel, ThreadAutoArchiveDuration, Client } from "discord.js";
|
||||
import type Parser from "rss-parser";
|
||||
import { parser } from "./config";
|
||||
import { processedItems, saveCurrentState } from "./storage";
|
||||
import type { RssSource } from "../type/types";
|
||||
import rssConfig from "@/../rss.toml";
|
||||
|
||||
export async function checkRssFeeds(client: Client): Promise<void> {
|
||||
console.log(`[${new Date().toISOString()}] Checking RSS feeds...`);
|
||||
|
||||
for (const [name, config] of Object.entries(rssConfig) as [string, RssSource][]) {
|
||||
try {
|
||||
await processRssFeed(name, config, client);
|
||||
} catch (error) {
|
||||
console.error(`[${name}] Failed to check RSS feed:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) => item.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 = item.link || item.guid || "";
|
||||
|
||||
if (!itemId || processed.has(itemId)) continue;
|
||||
|
||||
// 카테고리 필터링
|
||||
if (shouldFilterByCategory(config, item)) {
|
||||
console.log(
|
||||
`[${name}] Filtered by category: ${item.title} (${item.categories?.join(", ")})`
|
||||
);
|
||||
processed.add(itemId);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[${name}] New item found: ${item.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 = (item.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[] = [];
|
||||
|
||||
if (item.link) {
|
||||
parts.push(`# ${config.emoji} | [${item.title}](<${item.link}>)`);
|
||||
}
|
||||
|
||||
if (item.contentSnippet) {
|
||||
parts.push(`\n${item.content}\n`);
|
||||
}
|
||||
|
||||
if (item.pubDate) {
|
||||
const timestamp = Math.floor(new Date(item.pubDate).getTime() / 1000);
|
||||
parts.push(`\n-# 🕐 <t:${timestamp}:f>`);
|
||||
}
|
||||
|
||||
return parts.join("\n") || item.link || "No content available.";
|
||||
}
|
||||
35
src/lib/storage.ts
Normal file
35
src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { PROCESSED_FILE } from "./config";
|
||||
|
||||
export function loadProcessedItems(): Record<string, string[]> {
|
||||
if (existsSync(PROCESSED_FILE)) {
|
||||
try {
|
||||
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)])
|
||||
)
|
||||
);
|
||||
}
|
||||
11
src/type/toml.d.ts
vendored
Normal file
11
src/type/toml.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
declare module "*.toml" {
|
||||
const value: Record<string, {
|
||||
channel: string;
|
||||
tag: string;
|
||||
emoji: string;
|
||||
body: string;
|
||||
rss: string;
|
||||
category_filter?: string[];
|
||||
}>;
|
||||
export default value;
|
||||
}
|
||||
8
src/type/types.ts
Normal file
8
src/type/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export interface RssSource {
|
||||
channel: string;
|
||||
tag: string;
|
||||
emoji: string;
|
||||
body: string;
|
||||
rss: string;
|
||||
category_filter?: string[];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue