init
This commit is contained in:
commit
e176b1c094
17 changed files with 632 additions and 0 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
DISCORD_TOKEN=your_discord_bot_token_here
|
||||
53
.github/workflows/main.yml
vendored
Normal file
53
.github/workflows/main.yml
vendored
Normal file
|
|
@ -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 }}
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
|
|
@ -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"]
|
||||
15
README.md
Normal file
15
README.md
Normal file
|
|
@ -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.
|
||||
86
bun.lock
Normal file
86
bun.lock
Normal file
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
13
cli/git-commit-build.ts
Normal file
13
cli/git-commit-build.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
async function getGitCommitHash(): Promise<string> {
|
||||
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 {};
|
||||
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
12
rss.toml
Normal file
12
rss.toml
Normal file
|
|
@ -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 = ["유머&정보"]
|
||||
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[];
|
||||
}
|
||||
37
tsconfig.json
Normal file
37
tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue