commit e176b1c094abaced7bc183cbfc67fcff0c39d811 Author: imnyang Date: Thu Dec 11 01:16:37 2025 +0900 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1057b49 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DISCORD_TOKEN=your_discord_bot_token_here diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..92dc719 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,53 @@ +name: ci + +on: + push: + branches: + - main + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set version + run: | + if [[ "${GITHUB_REF}" == refs/heads/main ]]; then + echo "RELEASE_VERSION=latest" >> $GITHUB_ENV + else + echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: | + ghcr.io/imnyang/yanmang/bot:latest + ghcr.io/imnyang/yanmang/bot:${{ env.RELEASE_VERSION }} + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a44b21c --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +processed.json +version \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d69f0c3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM oven/bun:alpine as build + +# Set timezone to KST +ENV TZ=Asia/Seoul +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +WORKDIR /app + +# Copy dependency files first (improves layer caching) +COPY package.json bun.lock ./ + +# Install dependencies (cached until package files change) +RUN bun install --frozen-lockfile + +# Copy application code +COPY . . + +RUN bun run ./cli/git-commit-build.ts +RUN bun run build + +FROM alpine:latest as runtime + +ENV TZ=Asia/Seoul +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +WORKDIR /app + +COPY --from=build /app/yanmang . +COPY --from=build /app/version . + +CMD ["./yanmang"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a7602d --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# yanmang + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..228ec88 --- /dev/null +++ b/bun.lock @@ -0,0 +1,86 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "yanmang", + "dependencies": { + "discord.js": "^14.25.1", + "rss-parser": "^3.13.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="], + + "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], + + "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="], + + "@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="], + + "@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="], + + "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], + + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], + + "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], + + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], + + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + + "@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + + "discord-api-types": ["discord-api-types@0.38.36", "", {}, "sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ=="], + + "discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="], + + "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + + "magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="], + + "rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="], + + "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], + + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + } +} diff --git a/cli/git-commit-build.ts b/cli/git-commit-build.ts new file mode 100644 index 0000000..9767ec4 --- /dev/null +++ b/cli/git-commit-build.ts @@ -0,0 +1,13 @@ +async function getGitCommitHash(): Promise { + try { + const proc = Bun.spawn(["git", "rev-parse", "--short", "HEAD"]); + const text = await new Response(proc.stdout).text(); + return text.trim(); + } catch { + return "unknown"; + } +} + +Bun.file("version").write(await getGitCommitHash()); + +export {}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..836bc33 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "yanmang", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "start": "bun run ./src/index.ts", + "dev": "bun --hot run ./src/index.ts", + "build": "bun build ./src/index.ts --outfile yanmang --compile --minify", + "lint": "biome check .", + "lint:fix": "biome format ." + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "discord.js": "^14.25.1", + "rss-parser": "^3.13.0" + } +} \ No newline at end of file diff --git a/rss.toml b/rss.toml new file mode 100644 index 0000000..1f8918f --- /dev/null +++ b/rss.toml @@ -0,0 +1,12 @@ +[geeknews] +channel = "1448338163864174867" +tag = "1448338238963056691" +emoji = "πŸ€“" +rss = "https://feeds.feedburner.com/geeknews-feed" + +[svrforum] +channel = "1448338163864174867" +tag = "1448338289147908108" +emoji = "πŸ’»" +rss = "https://svrforum.com/rss" +category_filter = ["유머&정보"] \ No newline at end of file diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..e0a2dfa --- /dev/null +++ b/src/commands.ts @@ -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 { + 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."); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..aa9fc82 --- /dev/null +++ b/src/index.ts @@ -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); \ No newline at end of file diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..6d554f2 --- /dev/null +++ b/src/lib/config.ts @@ -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); +} diff --git a/src/lib/rss.ts b/src/lib/rss.ts new file mode 100644 index 0000000..e0c7195 --- /dev/null +++ b/src/lib/rss.ts @@ -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 { + 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 { + 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 { + 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-# πŸ• `); + } + + return parts.join("\n") || item.link || "No content available."; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..db39160 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,35 @@ +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { PROCESSED_FILE } from "./config"; + +export function loadProcessedItems(): Record { + if (existsSync(PROCESSED_FILE)) { + try { + return JSON.parse(readFileSync(PROCESSED_FILE, "utf-8")); + } catch (error) { + console.error("처리된 ν•­λͺ© 파일 읽기 μ‹€νŒ¨:", error); + } + } + return {}; +} + +export function saveProcessedItems(items: Record): void { + try { + writeFileSync(PROCESSED_FILE, JSON.stringify(items, null, 2)); + } catch (error) { + console.error("처리된 ν•­λͺ© 파일 μ €μž₯ μ‹€νŒ¨:", error); + } +} + +// 처리된 ν•­λͺ© Map +const processedData = loadProcessedItems(); +export const processedItems = new Map>( + 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)]) + ) + ); +} diff --git a/src/type/toml.d.ts b/src/type/toml.d.ts new file mode 100644 index 0000000..00ded64 --- /dev/null +++ b/src/type/toml.d.ts @@ -0,0 +1,11 @@ +declare module "*.toml" { + const value: Record; + export default value; +} diff --git a/src/type/types.ts b/src/type/types.ts new file mode 100644 index 0000000..bd6837f --- /dev/null +++ b/src/type/types.ts @@ -0,0 +1,8 @@ +export interface RssSource { + channel: string; + tag: string; + emoji: string; + body: string; + rss: string; + category_filter?: string[]; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9f900bb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + // Path aliases + "baseUrl": "./src", + "paths": { + "@/*": ["*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}