first commit

This commit is contained in:
yeongaori
2025-08-07 01:18:49 +09:00
commit 13bf4ca7a4
23 changed files with 2614 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
config/
data/
.env
src/commands/admin/
src/commands/general/

22
README.md Executable file
View File

@@ -0,0 +1,22 @@
# DiscordGaori
Rewritten [DiscordGaori](https://github.com/yeongaori/DiscordGaori-StarLight) with Node.js
## Features
### Rich Presence (RPC)
This bot supports Discord's Rich Presence feature, which allows you to customize the bot's status.
To configure the RPC, you need to set the following variables in your `.env` file:
- `RPC_ENABLED`: Set to `true` to enable RPC, or `false` to disable it.
- `RPC_ACTIVITY_NAME`: The name of the activity you want the bot to display (e.g., "with Gaori").
- `RPC_ACTIVITY_TYPE`: The type of activity. Can be one of `Playing`, `Listening`, `Watching`, `Streaming`, or `Competing`.
Example `.env` configuration:
```
RPC_ENABLED=true
RPC_ACTIVITY_NAME="전역일 계산"
RPC_ACTIVITY_TYPE=Playing
```

41
index.js Normal file
View File

@@ -0,0 +1,41 @@
require('dotenv').config();
const { Client, GatewayIntentBits, Partials } = require('discord.js');
const fs = require('fs');
const path = require('path');
const logger = require('./modules/colorfulLogger');
const { loadData } = require('./src/data/dataManager');
const { setupProcessHandlers } = require('./src/handlers/processHandlers');
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
partials: [Partials.Channel],
});
// Load data and setup process handlers
loadData();
setupProcessHandlers(client);
// Load event handlers
const eventsPath = path.join(__dirname, 'src', 'events');
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js'));
for (const file of eventFiles) {
const filePath = path.join(eventsPath, file);
const event = require(filePath);
if (event.once) {
client.once(event.name, (...args) => event.execute(...args));
} else {
client.on(event.name, (...args) => event.execute(...args));
}
}
logger.info('Attempting to log in with token...');
client.login(process.env.DISCORD_TOKEN).catch(e => {
logger.error(`Failed to login: ${e.message}`);
process.exit(1);
});

1078
index_bk.js Executable file

File diff suppressed because it is too large Load Diff

34
modules/colorfulLogger.js Executable file
View File

@@ -0,0 +1,34 @@
const colors = {
noformat: '\033[0m',
Fbold: '\033[1m',
Fgreen: '\x1b[32m',
Fblue: '\x1b[34m',
Fred: '\x1b[31m',
Fwhite: '\x1b[37m',
Cwhite: '\033[38;5;15m',
Clime: '\033[48;5;10m',
Cred: '\033[48;5;9m',
Cyellow: '\033[48;5;3m',
Cgreen: '\033[48;5;2m',
Ccyan: '\033[48;5;6m',
Corange: '\033[48;5;202m'
};
module.exports = {
info(...args) {
console.log(`${colors.Clime}${colors.Fwhite} INFO ${colors.noformat}`, ...args);
},
warn(...args) {
console.log(`${colors.Cyellow}${colors.Fwhite} WARN ${colors.noformat}`, ...args);
},
error(...args) {
console.error(`${colors.Cred}${colors.Fwhite} ERRO ${colors.noformat}`, ...args);
},
debug(...args) {
console.log(`${colors.Corange}${colors.Fwhite} DEBG ${colors.noformat}`, ...args);
},
term(...args) {
console.log(`${colors.Ccyan}${colors.Fwhite} TERM ${colors.noformat}`, ...args)
}
};

320
package-lock.json generated Executable file
View File

@@ -0,0 +1,320 @@
{
"name": "discordgaori",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "discordgaori",
"version": "1.0.0",
"license": "Apache-2.0",
"dependencies": {
"discord.js": "^14.18.0",
"dotenv": "^16.5.0"
}
},
"node_modules/@discordjs/builders": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.10.1.tgz",
"integrity": "sha512-OWo1fY4ztL1/M/DUyRPShB4d/EzVfuUvPTRRHRIt/YxBrUYSz0a+JicD5F5zHFoNs2oTuWavxCOVFV1UljHTng==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/formatters": "^0.6.0",
"@discordjs/util": "^1.1.1",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.37.119",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.0.tgz",
"integrity": "sha512-YIruKw4UILt/ivO4uISmrGq2GdMY6EkoTtD0oS0GvkJFRZbTSdPhzYiUILbJ/QslsvC9H9nTgGgnarnIl4jMfw==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.37.114"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.4.3.tgz",
"integrity": "sha512-+SO4RKvWsM+y8uFHgYQrcTl/3+cY02uQOH7/7bKbVZsTfrfpoE62o5p+mmV+s7FVhTX82/kQUGGbu4YlV60RtA==",
"license": "Apache-2.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.37.119",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/util": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz",
"integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.1.tgz",
"integrity": "sha512-PBvenhZG56a6tMWF/f4P6f4GxZKJTBG95n7aiGSPTnodmz4N5g60t79rSIAq7ywMbv8A4jFtexMruH+oe51aQQ==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.4.3",
"@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.37.119",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@types/node": {
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz",
"integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/discord-api-types": {
"version": "0.37.119",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.119.tgz",
"integrity": "sha512-WasbGFXEB+VQWXlo6IpW3oUv73Yuau1Ig4AZF/m13tXcTKnMpc/mHjpztIlz4+BM9FG9BHQkEXiPto3bKduQUg==",
"license": "MIT"
},
"node_modules/discord.js": {
"version": "14.18.0",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.18.0.tgz",
"integrity": "sha512-SvU5kVUvwunQhN2/+0t55QW/1EHfB1lp0TtLZUSXVHDmyHTrdOj5LRKdR0zLcybaA15F+NtdWuWmGOX9lE+CAw==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/builders": "^1.10.1",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.0",
"@discordjs/rest": "^2.4.3",
"@discordjs/util": "^1.1.1",
"@discordjs/ws": "^1.2.1",
"@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.37.119",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"tslib": "^2.6.3",
"undici": "6.21.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT"
},
"node_modules/magic-bytes.js": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.10.0.tgz",
"integrity": "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==",
"license": "MIT"
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/undici": {
"version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
"integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

25
package.json Executable file
View File

@@ -0,0 +1,25 @@
{
"name": "discordgaori",
"version": "1.0.0",
"description": "A simple discord bot with various functions includin military service related things.",
"main": "index.js",
"scripts": {
"start": "node index.js",
"deploy": "node deploy-commands.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/yeongaori/DiscordGaori.git"
},
"author": "yeongaori",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/yeongaori/DiscordGaori/issues"
},
"homepage": "https://github.com/yeongaori/DiscordGaori#readme",
"dependencies": {
"discord.js": "^14.18.0",
"dotenv": "^16.5.0"
}
}

View File

@@ -0,0 +1,119 @@
const { PermissionsBitField } = require('discord.js');
const logger = require('../../../modules/colorfulLogger');
const { getCelebrationConfig, getIpdaeData, getNotificationHistory, saveData } = require('../../data/dataManager');
const { addMonthsToDate, calculateDaysFromNow } = require('../../utils/dateUtils');
const { handleCommandError } = require('../../../utils/errorHandler');
async function checkAndSendCelebrationMessages(client) {
logger.info('Starting celebration check...');
try {
const celebrationConfig = getCelebrationConfig();
const ipdaeDatas = getIpdaeData();
const notificationHistory = getNotificationHistory();
if (!celebrationConfig?.milestones || !celebrationConfig.channelTopicKeyword) {
logger.warn('Celebration config is missing or incomplete. Skipping celebration check.');
return;
}
let notificationHistoryModifiedThisRun = false;
const today = new Date();
for (const userData of ipdaeDatas) {
if (!userData.id || !userData.date || !userData.type) continue;
let months;
switch (userData.type) {
case '육군': case '해병대': months = 18; break;
case '해군': months = 20; break;
case '공군': case '사회복무요원': months = 21; break;
default: continue;
}
const remainingDays = calculateDaysFromNow(addMonthsToDate(userData.date, months), today);
const userHistory = notificationHistory[userData.id] || [];
for (const milestone of celebrationConfig.milestones) {
if (remainingDays === milestone.days && !userHistory.includes(milestone.days)) {
const userToNotify = await client.users.fetch(userData.id).catch(() => null);
if (!userToNotify) continue;
for (const guild of client.guilds.cache.values()) {
const member = guild.members.cache.get(userData.id) || await guild.members.fetch(userData.id).catch(() => null);
if (member) {
const targetChannels = guild.channels.cache.filter(ch =>
ch.isTextBased() &&
ch.topic?.includes(celebrationConfig.channelTopicKeyword) &&
guild.members.me?.permissionsIn(ch).has(PermissionsBitField.Flags.SendMessages)
);
for (const channel of targetChannels.values()) {
try {
const { mentionUser = true, mentionEveryone = false } = milestone;
let messagePrefix = "";
const allowEveryoneMention = mentionEveryone && guild.members.me.permissionsIn(channel).has(PermissionsBitField.Flags.MentionEveryone);
if (allowEveryoneMention) messagePrefix += "@everyone ";
if (mentionUser) messagePrefix += `<@${userData.id}> `;
const displayName = member.displayName || userToNotify.username;
const finalMessageToSend = messagePrefix + milestone.message.replace(/{userName}/g, displayName);
await channel.send({
content: finalMessageToSend,
allowedMentions: { parse: allowEveryoneMention ? ['everyone'] : [], users: mentionUser ? [userData.id] : [] }
});
logger.info(`Sent ${milestone.days}-day celebration for ${displayName} to #${channel.name}.`);
if (!notificationHistory[userData.id]) {
notificationHistory[userData.id] = [];
}
notificationHistory[userData.id].push(milestone.days);
notificationHistoryModifiedThisRun = true;
} catch (sendError) {
logger.error(`Failed to send celebration message to #${channel.name} for ${member.displayName}: ${sendError.message}`);
}
}
}
}
}
}
}
if (notificationHistoryModifiedThisRun) {
await saveData('history');
}
logger.info('Celebration check finished.');
} catch (e) {
await handleCommandError(e, 'Celebration Check', client);
}
}
function scheduleDailyCelebrationCheck(client) {
try {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
tomorrow.setHours(0, 0, 5, 0);
let timeToFirstCheck = tomorrow.getTime() - now.getTime();
if (timeToFirstCheck < 0) {
tomorrow.setDate(tomorrow.getDate() + 1);
timeToFirstCheck = tomorrow.getTime() - now.getTime();
}
logger.info(`Next automatic celebration check scheduled for: ${tomorrow.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`);
setTimeout(() => {
checkAndSendCelebrationMessages(client);
setInterval(() => checkAndSendCelebrationMessages(client), 24 * 60 * 60 * 1000);
}, timeToFirstCheck);
} catch (e) {
handleCommandError(e, 'Schedule Celebration Check', client);
}
}
module.exports = { checkAndSendCelebrationMessages, scheduleDailyCelebrationCheck };

View File

@@ -0,0 +1,124 @@
const { SlashCommandBuilder, EmbedBuilder, InteractionContextType, ApplicationIntegrationType } = require('discord.js');
const logger = require('../../../modules/colorfulLogger');
const { getIpdaeData } = require('../../data/dataManager');
const {
addMonthsToDate,
calculateProgress,
calculateDateDifference,
formatDate,
dateFormatter,
calculateMonthDifference
} = require('../../utils/dateUtils');
const { createProgressBar, getNameById } = require('../../utils/discordUtils');
const { binarySearch, addSpace } = require('../../utils/helpers');
const commandData = new SlashCommandBuilder()
.setName('전역일')
.setDescription('전역일과 관련된 정보들을 확인합니다')
.setContexts(InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel)
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall)
.addUserOption(option => option.setName('유저').setDescription('전역일을 확인할 유저').setRequired(false))
.addIntegerOption(option => option.setName('소수점').setDescription('진행도의 소수점').setMinValue(0).setMaxValue(100).setRequired(false))
.addBooleanOption(option => option.setName('상세내용').setDescription('상세 내용 표시 여부').setRequired(false));
function getDischargeInfo(client, targetUserId, targetUserName, decimal, usedFullInfo) {
try {
const ipdaeDatas = getIpdaeData();
const userNameForEmbed = (targetUserName && targetUserName.trim() !== `Unknown User (${targetUserId})` && targetUserName.trim() !== '') ? `${targetUserName}` : `사용자 (${targetUserId})님`;
const index = binarySearch(ipdaeDatas, targetUserId);
if (index === -1) {
return new EmbedBuilder()
.setTitle('저장된 데이터가 없습니다')
.setDescription(`먼저 \`!입대일\` 또는 \`/입대일\` 명령어로 데이터를 저장해주세요.`)
.addFields({ name: '대상자', value: `${userNameForEmbed} (${targetUserId})` })
.setColor('#F44336');
}
const ipdaeData = ipdaeDatas[index];
let months = 0, startText = '입대일', endText = '전역일';
switch (ipdaeData.type) {
case '육군': case '해병대': months = 18; break;
case '해군': months = 20; break;
case '공군': case '사회복무요원': months = 21; break;
default: throw new Error(`Unknown service type "${ipdaeData.type}" for user ${targetUserId}`);
}
if (ipdaeData.type === '사회복무요원') { startText = '소집일'; endText = '소집해제일'; }
const startDate = ipdaeData.date;
const endDate = addMonthsToDate(startDate, months);
const now = new Date();
const currentProgress = calculateProgress(startDate, endDate, now);
const totalDays = calculateDateDifference(startDate, endDate) + 1;
const todayFormatted = formatDate(now);
let daysServed = new Date(todayFormatted) < new Date(startDate) ? 0 : calculateDateDifference(startDate, todayFormatted) + 1;
daysServed = Math.min(daysServed, totalDays);
const remainingDays = totalDays - daysServed;
const [sYearStr, sMonthStr, sDayStr] = startDate.split('-');
let pastMonths = calculateMonthDifference(sYearStr, sMonthStr, String(now.getFullYear()), String(now.getMonth() + 1));
if (sDayStr === '01') pastMonths++;
let militaryRank = '민간인';
if (remainingDays > 0) {
if (pastMonths <= 2) militaryRank = `이병 ${pastMonths + 1}호봉`;
else if (pastMonths <= 8) militaryRank = `일병 ${pastMonths - 2}호봉`;
else if (pastMonths <= 14) militaryRank = `상병 ${pastMonths - 8}호봉`;
else militaryRank = `병장 ${pastMonths - 14}호봉`;
}
const progressBarText = createProgressBar(currentProgress, 10, decimal || 4);
let dDayText;
if (remainingDays === 0) {
dDayText = 'D-Day';
} else if (remainingDays < 0) {
dDayText = `D+${-remainingDays}`;
} else {
dDayText = `D-${remainingDays}`;
}
if (!usedFullInfo) {
return new EmbedBuilder()
.setTitle(`${userNameForEmbed}${endText}`)
.addFields(
{ name: '현재 진행도', value: `${progressBarText}\n${militaryRank}${addSpace(Math.max(0, 14 - militaryRank.length))}${dDayText}` },
{ name: '전체 복무일', value: `${totalDays}` },
{ name: '현재 복무일', value: `${daysServed}` }
)
.setColor('#007FFF');
} else {
return new EmbedBuilder()
.setTitle(`${userNameForEmbed}${endText} 상세 정보`)
.addFields(
{ name: startText, value: dateFormatter(startDate) },
{ name: endText, value: dateFormatter(endDate) },
{ name: '현재 진행도', value: progressBarText },
{ name: '복무 형태', value: ipdaeData.type },
{ name: '전체 복무일', value: `${totalDays}` },
{ name: '현재 복무일', value: `${daysServed}` },
{ name: '남은 복무일', value: String(remainingDays) },
{ name: '현재 계급', value: militaryRank }
)
.setColor('#007FFF');
}
} catch (e) {
logger.error(`Error in getDischargeInfo for ${targetUserId}: ${e.message}`);
return new EmbedBuilder()
.setTitle('전역일 계산 중 오류')
.setDescription(`\`\`\`\n${e.message}\n\`\`\``)
.setColor('#F44336');
}
}
async function execute(interaction) {
const decimalArg = interaction.options.getInteger('소수점') || 4;
const targetUserOption = interaction.options.getUser('유저');
const targetUserId = targetUserOption ? targetUserOption.id : interaction.user.id;
const fullInfoArg = interaction.options.getBoolean('상세내용') || false;
const targetUserName = getNameById(interaction.client, targetUserId, interaction.guild);
logger.info(`Processing /전역일 for ${targetUserName} (${targetUserId}), requested by ${interaction.user.username} (${interaction.user.id})`);
await interaction.deferReply();
const resultEmbed = getDischargeInfo(interaction.client, targetUserId, targetUserName, decimalArg, fullInfoArg);
await interaction.editReply({ embeds: [resultEmbed] });
}
module.exports = {
data: commandData,
getDischargeInfo,
execute
};

View File

@@ -0,0 +1,116 @@
const { SlashCommandBuilder, EmbedBuilder, InteractionContextType, ApplicationIntegrationType } = require('discord.js');
const logger = require('../../../modules/colorfulLogger');
const {
ipdaeDatas,
notificationHistory,
saveData
} = require('../../data/dataManager');
const { serviceTypes, adminUserIds } = require('../../../config/constants');
const { dateFormatter } = require('../../utils/dateUtils');
const { binarySearch, binaryInsert } = require('../../utils/helpers');
const { getNameById } = require('../../utils/discordUtils');
const { handleCommandError } = require('../../../utils/errorHandler');
const commandData = new SlashCommandBuilder()
.setName('입대일')
.setDescription('입대일을 설정합니다')
.setContexts(InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel)
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall)
.addStringOption(option => option.setName('입대일').setDescription('형식: YYYY-MM-DD').setRequired(true))
.addStringOption(option => option.setName('복무형태').setDescription('복무 형태').setRequired(true)
.addChoices(
{ name: '육군', value: '육군' },
{ name: '해군', value: '해군' },
{ name: '공군', value: '공군' },
{ name: '해병대', value: '해병대' },
{ name: '사회복무요원', value: '사회복무요원' }
));
async function setEnlistmentDate(client, callerUserId, dateArg, typeArg, commandString, customTargetUserId) {
dateArg = String(dateArg || '').trim();
typeArg = String(typeArg || '').trim();
let targetUserId = customTargetUserId && adminUserIds.includes(callerUserId) ? String(customTargetUserId.replace(/\D/g, '')) : callerUserId;
const targetUserName = getNameById(client, targetUserId);
const startDateEmbedTitle = customTargetUserId ? `${targetUserName}(${targetUserId}) 님의 입대일이 저장되었습니다` : '입대일이 저장되었습니다';
try {
if (!(/^\d{4}-\d{2}-\d{2}$/.test(dateArg) && serviceTypes.includes(typeArg))) {
return new EmbedBuilder()
.setTitle('명령어 형식 오류')
.setDescription(`입력된 날짜: \`${dateArg}\`, 복무형태: \`${typeArg}\``)
.addFields({ name: '올바른 형식', value: `${commandString} YYYY-MM-DD ${serviceTypes.join('/')}` })
.setColor('#F44336');
}
const [year, month, day] = dateArg.split('-').map(Number);
const enlistDate = new Date(year, month - 1, day);
if (isNaN(enlistDate.getTime()) || enlistDate.getFullYear() !== year || enlistDate.getMonth() + 1 !== month || enlistDate.getDate() !== day) {
return new EmbedBuilder()
.setTitle('유효하지 않은 날짜입니다.')
.setDescription(`입력한 날짜 \`${dateArg}\`를 확인해주세요.`)
.setColor('#F44336');
}
let notificationHistoryModified = false;
const index = binarySearch(ipdaeDatas, targetUserId);
if (index !== -1) {
const oldDate = ipdaeDatas[index].date;
const oldType = ipdaeDatas[index].type;
if (targetUserId === '1107292307025301544' && typeArg !== '사회복무요원') {
typeArg = '사회복무요원';
logger.info(`User ID 1107292307025301544 type overridden to 사회복무요원.`);
}
ipdaeDatas[index].date = dateArg;
ipdaeDatas[index].type = typeArg;
if (oldDate !== dateArg || oldType !== typeArg) {
logger.info(`Enlistment data changed for ${targetUserId}. Resetting their notification history.`);
notificationHistory[targetUserId] = [];
notificationHistoryModified = true;
}
} else {
binaryInsert(ipdaeDatas, { id: targetUserId, date: dateArg, type: typeArg });
notificationHistory[targetUserId] = [];
notificationHistoryModified = true;
}
await saveData('ipdae');
if (notificationHistoryModified) {
await saveData('history');
}
const finalData = ipdaeDatas[binarySearch(ipdaeDatas, targetUserId)];
return new EmbedBuilder()
.setTitle(index !== -1 ? startDateEmbedTitle.replace('저장', '수정') : startDateEmbedTitle)
.addFields(
{ name: '입대일', value: dateFormatter(finalData.date) },
{ name: '복무 형태', value: finalData.type }
)
.setColor('#3BB143');
} catch (e) {
await handleCommandError(e, `Data Save for ${targetUserId}`, client);
return new EmbedBuilder()
.setTitle('데이터 저장/수정 중 오류')
.addFields({ name: '에러 메시지', value: e.message })
.setColor('#F44336');
}
}
async function execute(interaction) {
const slashStartDateArg = interaction.options.getString('입대일');
const slashStartTypeArg = interaction.options.getString('복무형태');
logger.info(`Processing /입대일 for ${interaction.user.username} (${interaction.user.id}) with date: ${slashStartDateArg}, type: ${slashStartTypeArg}`);
await interaction.deferReply();
const resultEmbed = await setEnlistmentDate(interaction.client, interaction.user.id, slashStartDateArg, slashStartTypeArg, '/입대일', undefined);
await interaction.editReply({ embeds: [resultEmbed] });
}
module.exports = {
data: commandData,
setEnlistmentDate,
execute
};

View File

@@ -0,0 +1,24 @@
const { handleCommandError } = require('../../utils/errorHandler');
const { onSlashCommand, loadCommands } = require('../handlers/slashCommandHandler');
const { onButton } = require('../handlers/buttonHandler');
module.exports = {
name: 'interactionCreate',
async execute(interaction) {
if (!interaction.client.commands) {
interaction.client.commands = loadCommands();
}
if (!interaction.isCommand() && !interaction.isButton()) return;
try {
if (interaction.isCommand()) {
await onSlashCommand(interaction);
} else if (interaction.isButton()) {
await onButton(interaction);
}
} catch (e) {
await handleCommandError(e, interaction, interaction.client);
}
},
};

View File

@@ -0,0 +1,16 @@
const { commandPrefix } = require('../../config/constants');
const { handleCommandError } = require('../../utils/errorHandler');
const { handleCommand } = require('../handlers/commandHandler');
module.exports = {
name: 'messageCreate',
async execute(message) {
if (message.author.bot || !message.content.startsWith(commandPrefix)) return;
try {
await handleCommand(message);
} catch (e) {
await handleCommandError(e, message, message.client);
}
},
};

25
src/events/ready.js Normal file
View File

@@ -0,0 +1,25 @@
const logger = require('../../modules/colorfulLogger');
const { handleCommandError } = require('../../utils/errorHandler');
const { checkAndSendCelebrationMessages, scheduleDailyCelebrationCheck } = require('../commands/military/celebration');
const { ActivityType } = require('discord.js');
const { initializeRPC } = require('../handlers/rpcHandler');
const { initializeKoreanbotsUpdate } = require('../handlers/koreanbotsHandler');
module.exports = {
name: 'ready',
once: true,
async execute(client) {
try {
logger.info(`Logged in as ${client.user.tag}`);
initializeRPC(client);
initializeKoreanbotsUpdate(client);
logger.info('Performing initial celebration check on startup...');
await checkAndSendCelebrationMessages(client);
scheduleDailyCelebrationCheck(client);
} catch (e) {
await handleCommandError(e, 'Ready Event', client);
}
},
};

View File

@@ -0,0 +1,29 @@
const { EmbedBuilder } = require('discord.js');
const logger = require('../../modules/colorfulLogger');
const { getNameById } = require('../utils/discordUtils');
const { handleCommandError } = require('../../utils/errorHandler');
async function onButton(interaction) {
try {
const { customId, user, guild } = interaction;
const userName = getNameById(interaction.client, user.id, guild);
logger.info(`Button interaction: ${customId} by ${userName} (${user.id})`);
const [type, value] = customId.split('_');
const resultEmbed = new EmbedBuilder()
.setTitle('Button Interaction Received')
.addFields(
{ name: 'Button Type', value: type || 'N/A' },
{ name: 'Button Value', value: value || 'N/A' },
{ name: 'Pressed by', value: `${userName} (${user.id})` }
)
.setColor(type === 'success' ? '#3BB143' : type === 'danger' ? '#F44336' : '#7289DA');
await interaction.reply({ embeds: [resultEmbed], ephemeral: true });
} catch (e) {
await handleCommandError(e, interaction, interaction.client);
}
}
module.exports = { onButton };

View File

@@ -0,0 +1,105 @@
const { EmbedBuilder } = require('discord.js');
const logger = require('../../modules/colorfulLogger');
const {
startDateCommands,
endDateCommands,
randomCommands,
diceCommands,
deleteCommands,
adminUserIds,
commandPrefix
} = require('../../config/constants');
const { setEnlistmentDate } = require('../commands/military/setEnlistmentDate');
const { getDischargeInfo } = require('../commands/military/getDischargeInfo');
const { handleGiftCode } = require('../commands/admin/giftCode');
const { handleEval } = require('../commands/admin/eval');
const { handleTest } = require('../commands/admin/test');
const { handleDelete } = require('../commands/admin/delete');
const { handleRandom } = require('../commands/general/random');
const { getNameById } = require('../utils/discordUtils');
async function handleCommand(message) {
const { channel, content, author, guild } = message;
const userId = author.id;
const userName = getNameById(message.client, userId, guild);
const contentWithoutPrefix = content.substring(commandPrefix.length);
const args = contentWithoutPrefix.trim().split(/\s+/);
const commandName = args[0].toLowerCase();
if (startDateCommands.includes(commandName)) {
logger.info(`Processing !${commandName} from ${userName} (${userId}) in ${guild ? `guild ${guild.name} (${guild.id})` : 'DM'}`);
const argStartDate = args[1];
const argStartType = args[2];
const customUserIdForAdmin = args[3] && adminUserIds.includes(userId) ? args[3] : undefined;
const resultEmbed = await setEnlistmentDate(message.client, userId, argStartDate, argStartType, `!${commandName}`, customUserIdForAdmin);
await channel.send({ embeds: [resultEmbed] });
return;
}
if (endDateCommands.includes(commandName)) {
logger.info(`Processing !${commandName} from ${userName} (${userId}) in ${guild ? `guild ${guild.name} (${guild.id})` : 'DM'}`);
let targetUserIdForCmd = userId;
let decimalArgForCmd = 4;
let decimalArgIndex = 1;
if (args[1]) {
const mentionMatch = args[1].match(/^<@!?(\d+)>$/);
if (mentionMatch) {
targetUserIdForCmd = mentionMatch[1];
decimalArgIndex = 2;
} else if (/^\d{17,19}$/.test(args[1])) {
targetUserIdForCmd = args[1];
decimalArgIndex = 2;
}
}
if (args[decimalArgIndex] && !isNaN(parseInt(args[decimalArgIndex]))) {
decimalArgForCmd = parseInt(args[decimalArgIndex]);
} else if (decimalArgIndex === 1 && args[1] && !isNaN(parseInt(args[1]))) {
decimalArgForCmd = parseInt(args[1]);
}
const targetUserNameForCmd = getNameById(message.client, targetUserIdForCmd, guild);
const resultEmbed = getDischargeInfo(message.client, targetUserIdForCmd, targetUserNameForCmd, decimalArgForCmd, false);
await channel.send({ embeds: [resultEmbed] });
return;
}
if (randomCommands.includes(commandName)) {
await handleRandom(message, args.slice(1));
return;
}
if (diceCommands.includes(commandName)) {
await handleRandom(message, ['1', '6']);
return;
}
if (commandName === 'gicode') {
await handleGiftCode(message, 'genshin');
return;
}
if (commandName === 'hsrcode') {
await handleGiftCode(message, 'hsr');
return;
}
if (commandName === 'eval') {
const evalCode = message.content.substring(commandPrefix.length + commandName.length).trim();
await handleEval(message, evalCode);
return;
}
if (commandName === 'test') {
await handleTest(message);
}
if (deleteCommands.includes(commandName)) {
await handleDelete(message.client, message);
return;
}
}
module.exports = { handleCommand };

View File

@@ -0,0 +1,107 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const logger = require('../../modules/colorfulLogger');
const configPath = path.join(__dirname, '../../config/koreanbotsConfig.json');
function getConfig() {
try {
const configData = fs.readFileSync(configPath, 'utf8');
return JSON.parse(configData);
} catch (error) {
logger.error(`Error reading or parsing koreanbotsConfig.json: ${error.message}`);
if (fs.existsSync(configPath)) {
const backupPath = `${configPath}.${Date.now()}.bak`;
try {
fs.renameSync(configPath, backupPath);
logger.info(`Backed up corrupted koreanbotsConfig.json to ${backupPath}`);
} catch (renameError) {
logger.error(`Failed to backup koreanbotsConfig.json: ${renameError.message}`);
return null;
}
}
const defaultConfig = {
"ENABLED": true,
"KOREANBOTS_TOKEN": "your-koreanbots-token-here",
"LOG_UPDATES": true,
"UPDATE_INTERVAL_SECONDS": 300
};
try {
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8');
logger.info('Created a new koreanbotsConfig.json with default values.');
return defaultConfig;
} catch (writeError) {
logger.error(`Failed to create new koreanbotsConfig.json: ${writeError.message}`);
return null;
}
}
}
function initializeKoreanbotsUpdate(client) {
const config = getConfig();
if (!config || !config.ENABLED) {
if (config && config.LOG_UPDATES) {
logger.info('Koreanbots update is disabled.');
}
return;
}
const update = () => {
const serverCount = client.guilds.cache.size;
const postData = JSON.stringify({ servers: serverCount });
const options = {
hostname: 'koreanbots.dev',
path: `/api/v2/bots/${process.env.CLIENT_ID}/stats`,
method: 'POST',
headers: {
'Authorization': config.KOREANBOTS_TOKEN,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (config.LOG_UPDATES) {
let logMessage = `Koreanbots API response: ${res.statusCode}`;
try {
const responseBody = JSON.parse(data);
if (responseBody.message) {
logMessage += ` - ${responseBody.message}`;
}
} catch (e) {
// Not a json response, do nothing
}
logger.info(logMessage);
}
});
});
req.on('error', (e) => {
if (config.LOG_UPDATES) {
logger.error(`Koreanbots API request error: ${e.message}`);
}
});
req.write(postData);
req.end();
};
const interval = (config.UPDATE_INTERVAL_SECONDS || 300) * 1000;
setInterval(update, interval);
logger.info(`Koreanbots update is enabled and will run every ${interval / 1000} seconds.`);
update();
}
module.exports = { initializeKoreanbotsUpdate };

View File

@@ -0,0 +1,31 @@
const logger = require('../../modules/colorfulLogger');
const { handleFatalError } = require('../../utils/errorHandler');
const { saveData } = require('../data/dataManager');
function setupProcessHandlers(client) {
async function gracefulShutdown(signal) {
logger.info(`${signal} signal received. Shutting down gracefully...`);
try {
await client.destroy();
await saveData('history');
process.exit(0);
} catch (e) {
logger.error(`Error during ${signal} shutdown: ${e.message}`);
process.exit(1);
}
}
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('uncaughtException', (error) => {
handleFatalError(error, 'Uncaught Exception', client, () => saveData('history'));
});
process.on('unhandledRejection', (reason) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
handleFatalError(error, 'Unhandled Rejection', client, () => saveData('history'));
});
}
module.exports = { setupProcessHandlers };

104
src/handlers/rpcHandler.js Normal file
View File

@@ -0,0 +1,104 @@
const fs = require('fs');
const path = require('path');
const logger = require('../../modules/colorfulLogger');
const { ActivityType } = require('discord.js');
const configPath = path.join(__dirname, '../../config/rpcConfig.json');
function getConfig() {
try {
const configData = fs.readFileSync(configPath, 'utf8');
return JSON.parse(configData);
} catch (error) {
logger.error(`Error reading or parsing rpcConfig.json: ${error.message}`);
if (fs.existsSync(configPath)) {
const backupPath = `${configPath}.${Date.now()}.bak`;
try {
fs.renameSync(configPath, backupPath);
logger.info(`Backed up corrupted rpcConfig.json to ${backupPath}`);
} catch (renameError) {
logger.error(`Failed to backup rpcConfig.json: ${renameError.message}`);
return null;
}
}
const defaultConfig = {
"RPC_ENABLED": true,
"RPC_INTERVAL_SECONDS": 60,
"LOG_RPC_CHANGES": true,
"RANDOMIZE_RPC": true,
"activities": [
{ "name": "전역일 계산", "type": "Playing" },
{ "name": "마음의 편지", "type": "Watching" },
{ "name": "국군도수체조", "type": "Playing" },
{ "name": "이등병의 편지", "type": "Listening" }
]
};
try {
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8');
logger.info('Created a new rpcConfig.json with default values.');
return defaultConfig;
} catch (writeError) {
logger.error(`Failed to create new rpcConfig.json: ${writeError.message}`);
return null;
}
}
}
function initializeRPC(client) {
const rpcConfig = getConfig();
if (!rpcConfig || !rpcConfig.RPC_ENABLED) {
logger.info('RPC is disabled.');
return;
}
const activities = rpcConfig.activities;
if (!activities || activities.length === 0) {
logger.warn('RPC is enabled, but no activities are defined in rpcConfig.json.');
return;
}
let activityIndex = 0;
const updateActivity = () => {
if (rpcConfig.RANDOMIZE_RPC) {
activityIndex = Math.floor(Math.random() * activities.length);
}
const activity = activities[activityIndex];
if (!activity || !activity.name || !activity.type) {
logger.warn(`Invalid activity at index ${activityIndex}. Skipping.`);
if (!rpcConfig.RANDOMIZE_RPC) {
activityIndex = (activityIndex + 1) % activities.length;
}
return;
}
client.user.setPresence({
activities: [{ name: activity.name, type: ActivityType[activity.type] }],
status: 'online',
});
if (rpcConfig.LOG_RPC_CHANGES) {
logger.info(`Activity changed to: ${activity.type} ${activity.name}`);
}
if (!rpcConfig.RANDOMIZE_RPC) {
activityIndex = (activityIndex + 1) % activities.length;
}
};
updateActivity();
const intervalSeconds = rpcConfig.RPC_INTERVAL_SECONDS || 60;
const intervalMilliseconds = intervalSeconds * 1000;
setInterval(updateActivity, intervalMilliseconds);
logger.info(`RPC is enabled and will rotate through activities every ${intervalSeconds} seconds.`);
}
module.exports = { initializeRPC };

View File

@@ -0,0 +1,46 @@
const { Collection } = require('discord.js');
const fs = require('fs');
const path = require('path');
const logger = require('../../modules/colorfulLogger');
const { handleCommandError } = require('../../utils/errorHandler');
function loadCommands() {
const commands = new Collection();
const commandFolders = fs.readdirSync(path.join(__dirname, '..', 'commands'));
for (const folder of commandFolders) {
const commandFiles = fs.readdirSync(path.join(__dirname, '..', 'commands', folder)).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
try {
const command = require(path.join(__dirname, '..', 'commands', folder, file));
if (command.data && command.execute) {
commands.set(command.data.name, command);
} else {
logger.warn(`Command file ${file} is missing a data or execute export.`);
}
} catch (error) {
logger.error(`Error loading command from ${file}:`, error);
}
}
}
return commands;
}
async function onSlashCommand(interaction) {
const command = interaction.client.commands.get(interaction.commandName);
if (!command) {
logger.warn(`No command matching ${interaction.commandName} was found.`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
logger.error(`Error executing ${interaction.commandName}`);
logger.error(error);
await handleCommandError(error, interaction, interaction.client);
}
}
module.exports = { onSlashCommand, loadCommands };

60
src/utils/dateUtils.js Normal file
View File

@@ -0,0 +1,60 @@
function addMonthsToDate(dateString, monthsToAdd) {
const date = new Date(dateString);
date.setMonth(date.getMonth() + monthsToAdd);
date.setDate(date.getDate() - 1);
return formatDate(date);
}
function dateFormatter(dateString) {
if (!dateString || !/^\d{4}-\d{2}-\d{2}$/.test(dateString)) return "날짜 형식 오류";
const [year, month, day] = dateString.split('-');
return `${year}${month}${day}`;
}
function calculateDaysFromNow(dateString, now = new Date()) {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
today.setHours(0,0,0,0);
const inputDate = new Date(dateString);
inputDate.setHours(0,0,0,0);
return Math.ceil((inputDate.getTime() - today.getTime()) / 86400000);
}
function calculateDateDifference(date1Str, date2Str) {
const d1 = new Date(date1Str);
d1.setHours(0, 0, 0, 0);
const d2 = new Date(date2Str);
d2.setHours(0, 0, 0, 0);
return Math.ceil(Math.abs(d2.getTime() - d1.getTime()) / 86400000);
}
function calculateProgress(startDateStr, endDateStr, now = new Date()) {
const start = new Date(startDateStr);
start.setHours(0, 0, 0, 0);
const end = new Date(endDateStr);
end.setHours(0, 0, 0, 0);
const currentTime = now.getTime();
if (start.getTime() >= end.getTime() || currentTime >= end.getTime()) return '100.0000000';
if (currentTime <= start.getTime()) return 0;
return Math.max(0, Math.min(100, ((currentTime - start.getTime()) / (end.getTime() - start.getTime())) * 100));
}
function calculateMonthDifference(sYear, sMonth, eYear, eMonth) {
return (Number(eYear) - Number(sYear)) * 12 + (Number(eMonth) - Number(sMonth));
}
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
module.exports = {
addMonthsToDate,
dateFormatter,
calculateDaysFromNow,
calculateDateDifference,
calculateProgress,
calculateMonthDifference,
formatDate
};

34
src/utils/discordUtils.js Normal file
View File

@@ -0,0 +1,34 @@
const { EmbedBuilder } = require('discord.js');
function getNameById(client, id, guild = null) {
const user = client.users.cache.get(id);
if (!user) return `Unknown User (${id})`;
if (guild) {
const member = guild.members.cache.get(id);
if (member) return member.displayName;
}
return user.displayName || user.username;
}
function createProgressBar(progress, totalLength, toFixedValue) {
progress = Math.max(0, Math.min(100, parseFloat(progress)));
toFixedValue = Number.isInteger(toFixedValue) && toFixedValue >= 0 ? toFixedValue : 4;
const filledLength = Math.round((progress / 100) * totalLength);
const filledBar = '█'.repeat(filledLength);
const unfilledBar = '░'.repeat(totalLength - filledLength);
return `[${filledBar}${unfilledBar}] ${progress.toFixed(toFixedValue)}%`;
}
async function sendEmbed(channel, title, description, color = '#007FFF') {
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(color);
return await channel.send({ embeds: [embed] });
}
module.exports = {
getNameById,
createProgressBar,
sendEmbed
};

47
src/utils/helpers.js Normal file
View File

@@ -0,0 +1,47 @@
function binarySearch(arr, id) {
let left = 0, right = arr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid].id === id) return mid;
if (arr[mid].id < id) left = mid + 1;
else right = mid - 1;
}
return -1;
}
function binaryInsert(arr, item) {
let left = 0, right = arr.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid].id < item.id) left = mid + 1;
else right = mid;
}
arr.splice(left, 0, item);
}
function addSpace(num) {
return num > 0 ? ' '.repeat(num) : '';
}
function generateRandomNumber(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function parseArgs(cmdStr) {
const args = [];
const matches = cmdStr.match(/(".*?"|'.*?'|[^"\s'|]+)+(?=\s*|\s*$)/g) || [];
for (const match of matches) {
args.push(match.replace(/^["']|["']$/g, ''));
}
return args;
}
module.exports = {
binarySearch,
binaryInsert,
addSpace,
generateRandomNumber,
parseArgs
};

101
utils/errorHandler.js Normal file
View File

@@ -0,0 +1,101 @@
const { EmbedBuilder } = require('discord.js');
require('dotenv').config();
const logger = require('../../modules/colorfulLogger');
const logErrorToConsole = (error, context) => {
console.error(`\n\x1b[31m[${new Date().toISOString()}] \x1b[33m[${context || 'ERROR'}]\x1b[0m`);
console.error(error);
};
const sendErrorToDiscord = async (error, client, contextInfo) => {
try {
const errorLogChannelId = process.env.ERROR_LOG_CHANNEL_ID;
if (!errorLogChannelId || !client?.channels) return;
const errorEmbed = new EmbedBuilder()
.setColor(0xFF0000)
.setTitle('오류 발생')
.setTimestamp();
if (typeof contextInfo === 'string') {
errorEmbed.addFields({ name: '오류 발생 위치', value: contextInfo });
} else if (contextInfo?.isInteraction) {
const interaction = contextInfo;
errorEmbed.setTitle('명령어 인터랙션 오류');
errorEmbed.addFields(
{ name: '명령어', value: `\`/${interaction.commandName}\``, inline: true },
{ name: '사용자', value: `${interaction.user.tag} (\`${interaction.user.id}\`)`, inline: true },
{ name: '서버/채널', value: `${interaction.guild.name} / #${interaction.channel.name}`, inline: false }
);
} else if (contextInfo?.author) {
const message = contextInfo;
errorEmbed.setTitle('메시지 처리 오류');
errorEmbed.addFields(
{ name: '메시지 내용', value: message.content.substring(0, 200) },
{ name: '사용자', value: `${message.author.tag} (\`${message.author.id}\`)`, inline: true },
{ name: '서버/채널', value: `${message.guild.name} / #${message.channel.name}`, inline: true }
);
}
errorEmbed.addFields(
{ name: '오류 메시지', value: `\`\`\`${error.message}\`\`\`` },
{ name: 'Stack Trace', value: `\`\`\`javascript\n${error.stack.substring(0, 1000)}\`\`\`` }
);
const channel = await client.channels.fetch(errorLogChannelId).catch(() => null);
if (channel?.isTextBased()) {
await channel.send({ embeds: [errorEmbed] });
}
} catch (discordError) {
console.error('\n\x1b[1m\x1b[31m[FATAL] Discord로 오류 로그 전송에 실패했습니다.\x1b[0m');
logErrorToConsole(discordError, 'DISCORD LOG SEND FAILED');
logErrorToConsole(error, 'ORIGINAL ERROR');
}
};
const handleCommandError = async (error, context, client) => {
logErrorToConsole(error, context?.commandName || context?.content || 'Command Execution');
await sendErrorToDiscord(error, client, context);
if (context?.isInteraction) {
const interaction = context;
const reply = {
content: '명령어를 실행하는 중 오류가 발생했습니다.',
embeds: [],
ephemeral: true,
};
try {
if (interaction.replied || interaction.deferred) {
await interaction.followUp(reply);
} else {
await interaction.reply(reply);
}
} catch (replyError) {
logErrorToConsole(replyError, 'COMMAND ERROR REPLY FAILED');
}
} else if (context?.channel) {
try {
await context.channel.send({ content: '명령어를 처리하는 중 오류가 발생했습니다.' });
} catch (replyError) {
logErrorToConsole(replyError, 'COMMAND ERROR REPLY FAILED');
}
}
};
const handleFatalError = async (error, context, client, shutdownLogic) => {
logErrorToConsole(error, context);
await sendErrorToDiscord(error, client, context);
if (typeof shutdownLogic === 'function') {
logger.error('Fatal error detected. Performing shutdown logic before exit...');
await shutdownLogic();
}
logger.error('치명적인 오류로 인해 프로세스를 종료합니다.');
process.exit(1);
};
module.exports = {
handleCommandError,
handleFatalError,
};