From 13bf4ca7a4ee6e911f9a0f29161dd9786a9d0a5d Mon Sep 17 00:00:00 2001 From: yeongaori Date: Thu, 7 Aug 2025 01:18:49 +0900 Subject: [PATCH] first commit --- .gitignore | 6 + README.md | 22 + index.js | 41 + index_bk.js | 1078 ++++++++++++++++++++ modules/colorfulLogger.js | 34 + package-lock.json | 320 ++++++ package.json | 25 + src/commands/military/celebration.js | 119 +++ src/commands/military/getDischargeInfo.js | 124 +++ src/commands/military/setEnlistmentDate.js | 116 +++ src/events/interactionCreate.js | 24 + src/events/messageCreate.js | 16 + src/events/ready.js | 25 + src/handlers/buttonHandler.js | 29 + src/handlers/commandHandler.js | 105 ++ src/handlers/koreanbotsHandler.js | 107 ++ src/handlers/processHandlers.js | 31 + src/handlers/rpcHandler.js | 104 ++ src/handlers/slashCommandHandler.js | 46 + src/utils/dateUtils.js | 60 ++ src/utils/discordUtils.js | 34 + src/utils/helpers.js | 47 + utils/errorHandler.js | 101 ++ 23 files changed, 2614 insertions(+) create mode 100644 .gitignore create mode 100755 README.md create mode 100644 index.js create mode 100755 index_bk.js create mode 100755 modules/colorfulLogger.js create mode 100755 package-lock.json create mode 100755 package.json create mode 100644 src/commands/military/celebration.js create mode 100644 src/commands/military/getDischargeInfo.js create mode 100644 src/commands/military/setEnlistmentDate.js create mode 100644 src/events/interactionCreate.js create mode 100644 src/events/messageCreate.js create mode 100644 src/events/ready.js create mode 100644 src/handlers/buttonHandler.js create mode 100644 src/handlers/commandHandler.js create mode 100644 src/handlers/koreanbotsHandler.js create mode 100644 src/handlers/processHandlers.js create mode 100644 src/handlers/rpcHandler.js create mode 100644 src/handlers/slashCommandHandler.js create mode 100644 src/utils/dateUtils.js create mode 100644 src/utils/discordUtils.js create mode 100644 src/utils/helpers.js create mode 100644 utils/errorHandler.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c619ade --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +config/ +data/ +.env +src/commands/admin/ +src/commands/general/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..a5a6791 --- /dev/null +++ b/README.md @@ -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 +``` \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..624c285 --- /dev/null +++ b/index.js @@ -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); +}); \ No newline at end of file diff --git a/index_bk.js b/index_bk.js new file mode 100755 index 0000000..d5a30d3 --- /dev/null +++ b/index_bk.js @@ -0,0 +1,1078 @@ +const { Client, SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionsBitField } = require('discord.js'); +const fs = require('fs').promises; +const path = require('path'); +const logger = require('./modules/colorfulLogger'); // 사용자가 제공한 로거 모듈 + +// Constants +const defaultDirectory = path.join('/home', 'yeongaori', 'DiscordGaori', '조교가오리'); +const dataDir = path.join(defaultDirectory, 'data'); // 데이터 디렉토리 경로 +const testDataDirectory = path.join(dataDir, 'testData.txt'); // 원본 경로 유지 (현재 코드에선 직접 사용 안함) +const ipdaeDataDirectory = path.join(dataDir, 'ipdaeData.json'); +const celebrationConfigPath = path.join(dataDir, 'celebrationConfig.json'); +const notificationHistoryPath = path.join(dataDir, 'notificationHistory.json'); // 알림 이력 파일 경로 +const commandPrefix = "!"; + +// ... 기존 require 및 상수, 전역 변수 선언 이후 ... + +// 안전한 종료를 위한 함수 +async function saveNotificationHistoryBeforeExit() { + logger.info('Attempting to save notification history before exit...'); + try { + // 현재 메모리의 notificationHistory 객체를 파일에 덮어쓰기 + await fs.writeFile(notificationHistoryPath, JSON.stringify(notificationHistory, null, 4), 'utf8'); + logger.info('notificationHistory.json saved successfully on exit.'); + } catch (e) { + logger.error(`Error saving notificationHistory.json on exit: ${e.message}`); + } +} + +// 프로세스 종료 신호 감지 (Ctrl+C 등) +process.on('SIGINT', async () => { + logger.info('SIGINT signal received. Shutting down gracefully...'); + await client.destroy(); // Discord 클라이언트 연결 종료 + await saveNotificationHistoryBeforeExit(); // 알림 이력 저장 + process.exit(0); // 정상 종료 +}); + +// 프로세스 종료 신호 감지 (kill 명령어 등) +process.on('SIGTERM', async () => { + logger.info('SIGTERM signal received. Shutting down gracefully...'); + await client.destroy(); // Discord 클라이언트 연결 종료 + await saveNotificationHistoryBeforeExit(); // 알림 이력 저장 + process.exit(0); // 정상 종료 +}); + +// 처리되지 않은 예외 발생 시 (최후의 수단) +process.on('uncaughtException', async (error, origin) => { + logger.error('!!!!!!!!!! UNCAUGHT EXCEPTION !!!!!!!!!!!'); + logger.error(`Error: ${error.message}`); + logger.error(`Origin: ${origin}`); + logger.error(error.stack); + // 이 시점에서는 프로그램 상태가 불안정할 수 있으므로, 저장은 최선책일 뿐 보장되지 않음 + // await saveNotificationHistoryBeforeExit(); + process.exit(1); // 오류와 함께 종료 +}); + +// 처리되지 않은 프로미스 거부 발생 시 +process.on('unhandledRejection', async (reason, promise) => { + logger.error('!!!!!!!!!! UNHANDLED REJECTION !!!!!!!!!!!'); + logger.error(`Promise: ${promise}`); + logger.error(`Reason: ${reason instanceof Error ? reason.message : reason}`); + if (reason instanceof Error) { + logger.error(reason.stack); + } + // await saveNotificationHistoryBeforeExit(); + // process.exit(1); // 오류와 함께 종료 (선택 사항) +}); + +// ... (기존의 IIFE 데이터 로딩 로직 및 나머지 코드 시작) ... + +// Client setup +const client = new Client({ intents: ['Guilds', 'GuildMessages', 'MessageContent'] }); +let ipdaeDatas = []; // 사용자 입대 정보 { id, date, type } +let celebrationConfig = {}; // 축하 메시지 설정 +let notificationHistory = {}; // 사용자별 알림 받은 기념일 목록 { userId: [milestone1, milestone2], ... } + +// 데이터 디렉토리 존재 확인 및 생성 (봇 시작 시 실행) +(async () => { + try { + await fs.mkdir(dataDir, { recursive: true }); + logger.info(`Data directory ensured: ${dataDir}`); + } catch (e) { + logger.error(`Failed to create data directory ${dataDir}: ${e.message}`); + } +})(); + +// 입대 데이터, 알림 이력, 축하 설정 데이터 로드 및 마이그레이션 (봇 시작 시 실행) +(async () => { + let ipdaeDataNeedsSaveAfterMigration = false; + let notificationHistoryNeedsSaveAfterMigration = false; + + // 1. ipdaeData.json 로드 + try { + const data = await fs.readFile(ipdaeDataDirectory, 'utf8'); + ipdaeDatas = JSON.parse(data); + ipdaeDatas.sort((a, b) => a.id.localeCompare(b.id)); // ID 기준으로 정렬 + logger.info('ipdaeData.json loaded and parsed.'); + } catch (e) { + logger.warn(`Failed to load ipdaeData.json: ${e.message}. Initializing as empty array.`); + ipdaeDatas = []; + if (e.code === 'ENOENT') { // 파일이 없을 경우 + try { + await fs.writeFile(ipdaeDataDirectory, JSON.stringify([], null, 4), 'utf8'); + logger.info('Created empty ipdaeData.json.'); + } catch (writeError) { + logger.error(`Failed to create empty ipdaeData.json: ${writeError.message}`); + } + } + } + + // 2. notificationHistory.json 로드 + try { + const historyData = await fs.readFile(notificationHistoryPath, 'utf8'); + notificationHistory = JSON.parse(historyData); + logger.info('notificationHistory.json loaded and parsed.'); + } catch (e) { + logger.warn(`notificationHistory.json not found or invalid: ${e.message}. Initializing as empty object.`); + notificationHistory = {}; + if (e.code === 'ENOENT') { // 파일이 없을 경우 + try { + await fs.writeFile(notificationHistoryPath, JSON.stringify({}, null, 4), 'utf8'); + logger.info('Created empty notificationHistory.json.'); + } catch (writeError) { + logger.error(`Failed to create empty notificationHistory.json: ${writeError.message}`); + } + } + } + + // 3. 마이그레이션: ipdaeData.json의 notifiedMilestones -> notificationHistory.json + // 이 로직은 ipdaeData.json에 notifiedMilestones 필드가 더 이상 없을 때까지 실행될 수 있음 + for (let i = 0; i < ipdaeDatas.length; i++) { + const user = ipdaeDatas[i]; + if (user.hasOwnProperty('notifiedMilestones')) { // 이전 버전 필드 존재 여부 확인 + if (Array.isArray(user.notifiedMilestones) && user.notifiedMilestones.length > 0) { + // notificationHistory에 해당 유저의 데이터가 없거나 비어있을 경우에만 마이그레이션 + if (!notificationHistory[user.id] || Object.keys(notificationHistory[user.id]).length === 0) { + notificationHistory[user.id] = [...user.notifiedMilestones]; // 배열 복사 + notificationHistoryNeedsSaveAfterMigration = true; + logger.info(`Migrated notifiedMilestones for user ${user.id} to notificationHistory.`); + } else { + logger.info(`User ${user.id} already has data in notificationHistory.json. Old notifiedMilestones in ipdaeData.json will be removed without merging.`); + } + } + delete user.notifiedMilestones; // ipdaeData 객체에서 필드 삭제 + ipdaeDataNeedsSaveAfterMigration = true; + } + } + + + if (ipdaeDataNeedsSaveAfterMigration) { + try { + await fs.writeFile(ipdaeDataDirectory, JSON.stringify(ipdaeDatas, null, 4), 'utf8'); + logger.info('ipdaeData.json updated after migrating/removing notifiedMilestones field.'); + } catch (e) { + logger.error(`Failed to save ipdaeData.json during migration: ${e.message}`); + } + } + if (notificationHistoryNeedsSaveAfterMigration) { + try { + await fs.writeFile(notificationHistoryPath, JSON.stringify(notificationHistory, null, 4), 'utf8'); + logger.info('notificationHistory.json saved after migration.'); + } catch (e) { + logger.error(`Failed to save notificationHistory.json during migration: ${e.message}`); + } + } + + // 4. celebrationConfig.json 로드 + try { + const configData = await fs.readFile(celebrationConfigPath, 'utf8'); + celebrationConfig = JSON.parse(configData); + // 이전 버전 호환: 멘션 관련 필드가 없는 경우 기본값 설정 + if (celebrationConfig.milestones && Array.isArray(celebrationConfig.milestones)) { + celebrationConfig.milestones.forEach(m => { + if (typeof m.mentionUser === 'undefined') m.mentionUser = true; + if (typeof m.mentionEveryone === 'undefined') m.mentionEveryone = false; + }); + } else { // milestones 필드가 없거나 배열이 아니면 기본 설정으로 초기화 + throw new Error("Milestones data is missing or invalid in celebrationConfig.json"); + } + logger.info('celebrationConfig.json loaded and parsed.'); + } catch (e) { + logger.warn(`celebrationConfig.json error: ${e.message}. Creating/Using default config.`); + celebrationConfig = { + channelTopicKeyword: "전역축하알림", + milestones: [ + { days: 100, message: "🎉 {userName}님, 전역까지 D-100일! 영광의 그날까지 파이팅입니다! 🎉", mentionUser: true, mentionEveryone: false }, + { days: 50, message: "🥳 {userName}님, 전역까지 D-50일! 이제 절반의 고지를 넘었습니다! 🥳", mentionUser: true, mentionEveryone: false }, + { days: 30, message: "🗓️ {userName}님, 전역까지 D-30일! 한 달 뒤면 자유의 몸! 🗓️", mentionUser: true, mentionEveryone: false }, + { days: 7, message: "✨ {userName}님, 전역까지 D-7일! 일주일만 버티면 됩니다! ✨", mentionUser: true, mentionEveryone: false }, + { days: 1, message: "🚀 {userName}님, 드디어 내일 전역입니다! 사회로 나갈 준비 되셨나요? 🚀", mentionUser: true, mentionEveryone: false }, + { days: 0, message: "🫡 {userName}님, 🎉축 전역🎉 대한민국의 자랑스러운 아들! 그동안 정말 고생 많으셨습니다! 🫡", mentionUser: true, mentionEveryone: true } + ] + }; + try { + await fs.writeFile(celebrationConfigPath, JSON.stringify(celebrationConfig, null, 4), 'utf8'); + logger.info(`Default celebrationConfig.json created/used at ${celebrationConfigPath}`); + } catch (saveError) { + logger.error(`Failed to save default celebrationConfig.json: ${saveError.message}`); + } + } +})(); + +client.once('ready', async readyClient => { + logger.info(`Logged in as ${readyClient.user.tag}`); + await registerSlashCommands(); + logger.info('Performing initial celebration check on startup...'); + await checkAndSendCelebrationMessages(); + scheduleDailyCelebrationCheck(); +}); + +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); +} + +async function resetSlashCommands() { + try { + await client.application.commands.set([]); + logger.info('Successfully reset all slash commands.'); + } catch (error) { + logger.error(`Failed to reset slash commands: ${error.message}`); + } +} + +async function registerSlashCommands() { + const commands = [ + new SlashCommandBuilder() + .setName('전역일') + .setDescription('전역일과 관련된 정보들을 확인합니다') + .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)), + new SlashCommandBuilder() + .setName('입대일') + .setDescription('입대일을 설정합니다') + .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: '사회복무요원' } + )) + ]; + try { + await client.application.commands.set(commands); + logger.info('Successfully registered slash commands.'); + } catch (error) { + logger.error(`Failed to register slash commands: ${error.message}`); + } +} + +client.on('interactionCreate', async interaction => { + if (!interaction.isCommand()) return; + await onSlashCommandCreate(interaction); +}); + +client.on('interactionCreate', async interaction => { + if (!interaction.isButton()) return; + await onMessageComponentCreate(interaction); +}); + +client.on('messageCreate', async message => { + if (message.author.bot) return; + await onMessageCreate(message); +}); + +const loadingEmbed = new EmbedBuilder() + .setTitle('데이터 로드 중...') + .setDescription('데이터를 불러오는 중입니다. 잠시만 기다려 주세요.') + .setColor('#FDFD96'); + +async function onSlashCommandCreate(interaction) { + let currentCommand = 'onSlashCommandCreateEvent'; + const { commandName, user, options, guild } = interaction; + const userId = user.id; + const userName = getNameById(userId, guild); + + try { + if (commandName === '입대일') { + currentCommand = 'startDateSlashCommand'; + const slashStartDateArg = options.getString('입대일'); + const slashStartTypeArg = options.getString('복무형태'); + + logger.info(`Processing /입대일 for ${userName} (${userId}) with date: ${slashStartDateArg}, type: ${slashStartTypeArg}`); + + await interaction.deferReply(); + const resultEmbed = await getStartDateEmbed(userId, slashStartDateArg, slashStartTypeArg, '/입대일', undefined); + await interaction.editReply({ embeds: [resultEmbed] }); + } + if (commandName === '전역일') { + currentCommand = 'endDateSlashCommand'; + let decimalArg = options.getInteger('소수점') || 4; + let targetUserOption = options.getUser('유저'); + let targetUserId = targetUserOption ? targetUserOption.id : userId; + const fullInfoArg = options.getBoolean('상세내용') || false; + + const targetUserName = getNameById(targetUserId, guild); + + logger.info(`Processing /전역일 for ${targetUserName} (${targetUserId}), requested by ${userName} (${userId})`); + + await interaction.deferReply(); + const resultEmbed = getEndDateEmbed(targetUserId, targetUserName, decimalArg, fullInfoArg); + await interaction.editReply({ embeds: [resultEmbed] }); + } + } catch (e) { + logger.error(`Error in ${currentCommand} for ${userName} (${userId}): ${e.message} \nStack: ${e.stack}`); + const embed = new EmbedBuilder() + .setTitle(`${currentCommand} 실행 중 문제가 발생했습니다`) + .addFields({ name: '에러 메시지', value: e.message }) + .setColor('#F44336'); + if (interaction.replied || interaction.deferred) { + await interaction.editReply({ embeds: [embed], ephemeral: true }); + } else { + try { + await interaction.reply({ embeds: [embed], ephemeral: true }); + } catch (replyError) { + logger.error(`Failed to send error reply for ${currentCommand}: ${replyError.message}`); + } + } + } +} + +async function onMessageCreate(event) { + let currentCommand = 'onMessageCreateEvent'; + const { channel, content, author, guild } = event; + const userId = author.id; + const userName = getNameById(userId, guild); + + async function send(data) { + if (typeof data === 'string') { + return channel.send(data); + } + return channel.send({ embeds: [data] }); + } + + try { + if (!content.startsWith(commandPrefix)) return; + const message = content.substring(1); + const args = message.split(' '); + const commandNameFromMessage = args[0].toLowerCase(); + + const startDateCommands = ['입대일', 'ㅇㄷㅇ', '소집일', 'ㅅㅈㅇ']; + if (startDateCommands.includes(commandNameFromMessage)) { + currentCommand = commandPrefix + commandNameFromMessage; + logger.info(`Processing ${currentCommand} from ${userName} (${userId}) with args: ${args.slice(1).join(' ')}`); + const argStartDate = args[1]; + const argStartType = args[2]; + const customUserIdForAdmin = args[3] && userId === '602447697047191562' ? args[3] : undefined; + const resultEmbed = await getStartDateEmbed(userId, argStartDate, argStartType, currentCommand, customUserIdForAdmin); + await send(resultEmbed); + return; + } + + const endDateCommands = ['전역일', 'ㅈㅇㅇ', '소집해제일', 'ㅅㅈㅎㅈㅇ', '소해일', 'ㅅㅎㅇ']; + if (endDateCommands.includes(commandNameFromMessage)) { + currentCommand = commandPrefix + commandNameFromMessage; + logger.info(`Processing ${currentCommand} from ${userName} (${userId}) with args: ${args.slice(1).join(' ')}`); + + 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(targetUserIdForCmd, guild); + const resultEmbed = getEndDateEmbed(targetUserIdForCmd, targetUserNameForCmd, decimalArgForCmd, false); + await send(resultEmbed); + return; + } + + const randomCommands = ['랜덤', 'ㄹㄷ', 'random']; + if (randomCommands.includes(commandNameFromMessage)) { + currentCommand = commandPrefix + commandNameFromMessage; + const randomArgs = args.slice(1); + if (randomArgs.length === 0) { + await send(String(generateRandomNumber(1, 100))); + } else if (randomArgs.length === 1) { + const max = parseInt(randomArgs[0]); + if (isNaN(max)) { + await send({ embeds: [new EmbedBuilder().setTitle('유효한 숫자가 아닙니다.').setColor('#F44336')] }); + } else { + await send(String(generateRandomNumber(1, max))); + } + } else if (randomArgs.length === 2) { + const min = parseInt(randomArgs[0]); + const max = parseInt(randomArgs[1]); + if (isNaN(min) || isNaN(max) || min > max) { + await send({ embeds: [new EmbedBuilder().setTitle('올바르지 않은 형식입니다. min > max 이거나 숫자가 아닙니다.').setColor('#F44336')] }); + } else { + await send(String(generateRandomNumber(min, max))); + } + } + return; + } + + const diceCommands = ['주사위', 'ㅈㅅㅇ', '다이스', 'ㄷㅇㅅ', 'dice']; + if (diceCommands.includes(commandNameFromMessage)) { + currentCommand = commandPrefix + commandNameFromMessage; + await send(String(generateRandomNumber(1, 6))); + return; + } + + if (message.startsWith('gicode')) { + currentCommand = commandPrefix + 'gicode'; + const giArgs = parseArgs(message); + const title = giArgs[1]; + if (giArgs.length < 3) { + const embed = new EmbedBuilder() + .setTitle('인수 오류').setDescription('지정되지 않은 인수가 있습니다') + .addFields({ name: '명령어 형식', value: `${commandPrefix}gicode 제목 코드1 코드2...` }) + .setColor('#F44336'); + await send(embed); return; + } + if (userId === '602447697047191562') { + const buttons = giArgs.slice(2).map(code => + new ButtonBuilder().setLabel(code).setStyle(ButtonStyle.Link).setURL(`https://genshin.hoyoverse.com/gift?code=${code}`) + ).slice(0, 5); + const row = new ActionRowBuilder().addComponents(buttons); + await channel.send({ content: `# ${title}`, components: [row] }); + if (event.deletable) await event.delete().catch(e => logger.warn(`Failed to delete gicode trigger message: ${e.message}`)); + } else { + await send({ embeds: [new EmbedBuilder().setTitle('이 명령어를 실행할 권한이 없습니다.').setColor('#F44336')] }); + } + return; + } + + if (message.startsWith('hsrcode')) { + currentCommand = commandPrefix + 'hsrcode'; + const hsrArgs = parseArgs(message); + const title = hsrArgs[1]; + if (hsrArgs.length < 3) { + const embed = new EmbedBuilder() + .setTitle('인수 오류').setDescription('지정되지 않은 인수가 있습니다') + .addFields({ name: '명령어 형식', value: `${commandPrefix}hsrcode 제목 코드1 코드2...` }) + .setColor('#F44336'); + await send(embed); return; + } + if (userId === '602447697047191562') { + const buttons = hsrArgs.slice(2).map(code => + new ButtonBuilder().setLabel(code).setStyle(ButtonStyle.Link).setURL(`https://hsr.hoyoverse.com/gift?code=${code}`) + ).slice(0, 5); + const row = new ActionRowBuilder().addComponents(buttons); + await channel.send({ content: `# ${title}`, components: [row] }); + if (event.deletable) await event.delete().catch(e => logger.warn(`Failed to delete hsrcode trigger message: ${e.message}`)); + } else { + await send({ embeds: [new EmbedBuilder().setTitle('이 명령어를 실행할 권한이 없습니다.').setColor('#F44336')] }); + } + return; + } + + if (message.startsWith('eval')) { + currentCommand = commandPrefix + 'eval'; + if (userId === '602447697047191562') { + const jsCode = content.slice(content.indexOf('eval') + 'eval'.length).trim(); + if (![' ', '\n'].includes(message.charAt(4)) && message.charAt(4) !== undefined ) { + await send({ embeds: [new EmbedBuilder().setTitle('잘못된 인수입니다. eval 명령어 다음에는 공백이 필요합니다.').setColor('#F44336')] }); + return; + } + if (!jsCode) { + await send({ embeds: [new EmbedBuilder().setTitle('실행할 코드가 없습니다.').setColor('#F44336')] }); + return; + } + try { + const result = await (async () => { + // eslint-disable-next-line no-eval + return await eval(jsCode); + })(); + await send(`Input:\n\`\`\`javascript\n${jsCode}\n\`\`\`\nOutput:\n\`\`\`\n${String(result)}\n\`\`\``); + } catch (e) { + await send({ embeds: [new EmbedBuilder().setTitle('Eval 실행 중 문제가 발생했습니다').addFields({ name: '에러 메시지', value: e.message }).setColor('#F44336')] }); + } + } else { + await send({ embeds: [new EmbedBuilder().setTitle('이 명령어를 실행할 권한이 없습니다.').setColor('#F44336')] }); + } + return; + } + + if (message.startsWith('test')) { + currentCommand = commandPrefix + 'test'; + if (userId === '602447697047191562') { + await send('Test command executed.'); + const testArg = args[1] || 'default_test_value'; + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`success_${testArg}`).setLabel('Success!').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId(`danger_${testArg}`).setLabel('Danger...').setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId(`secondary_${testArg}`).setLabel('Secondary Option').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setLabel('Test Link').setStyle(ButtonStyle.Link).setURL('https://www.google.com/') + ); + try { + const imagePath = path.join(defaultDirectory, '1.jpg'); + await fs.access(imagePath); + await channel.send({ + content: `Text for MessageBuilder: ${testArg}`, + files: [imagePath], + components: [row] + }); + } catch (fileError) { + logger.warn(`Test command: File 1.jpg not found or unreadable. Error: ${fileError.message}`); + await channel.send({ + content: `Text for MessageBuilder (file 1.jpg not found): ${testArg}`, + components: [row] + }); + } + await send('Test command finished executing.'); + } else { + await send({ embeds: [new EmbedBuilder().setTitle('이 명령어를 실행할 권한이 없습니다.').setColor('#F44336')] }); + } + return; + } + + } catch (e) { + logger.error(`Error in ${currentCommand} for ${userName} (${userId}): ${e.message} \nStack: ${e.stack}`); + await send({ embeds: [new EmbedBuilder().setTitle(`${currentCommand} 실행 중 문제가 발생했습니다`).addFields({ name: '에러 메시지', value: e.message }).setColor('#F44336')] }); + } +} + +async function onMessageComponentCreate(interaction) { + const { customId, user, guild } = interaction; + const userName = getNameById(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'); + + try { + await interaction.reply({ embeds: [resultEmbed], ephemeral: true }); + } catch (e) { + logger.error(`Failed to reply to button interaction ${customId}: ${e.message}`); + } +} + +async function getStartDateEmbed(callerUserId, dateArg, typeArg, commandString, customTargetUserId) { + let isSaved = false; + let notificationHistoryModified = false; // 이 함수 내에서 notificationHistory 변경 여부 + dateArg = String(dateArg || '').trim(); + typeArg = String(typeArg || '').trim(); + let startDateEmbedTitle = ''; + let targetUserId = callerUserId; + + if (customTargetUserId && callerUserId === '602447697047191562') { + targetUserId = String(customTargetUserId.replace(/\D/g, '')); + const targetUserName = getNameById(targetUserId); + startDateEmbedTitle = `${targetUserName}(${targetUserId}) 님의 입대일이 저장되었습니다`; + } else { + startDateEmbedTitle = '입대일이 저장되었습니다'; + } + + if (!(/^\d{4}-\d{2}-\d{2}$/.test(dateArg) && ['육군', '해군', '공군', '해병대', '사회복무요원'].includes(typeArg))) { + return new EmbedBuilder() + .setTitle('명령어 형식 오류') + .setDescription(`입력된 날짜: \`${dateArg}\`, 복무형태: \`${typeArg}\``) + .addFields({ name: '올바른 형식', value: `${commandString} YYYY-MM-DD 육군/해군/공군/해병대/사회복무요원` }) + .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'); + } + + const index = binarySearch(ipdaeDatas, targetUserId); + if (index !== -1) { // 기존 사용자 정보 수정 + isSaved = true; + 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; + startDateEmbedTitle = startDateEmbedTitle.replace('저장', '수정'); + + // 입대일 또는 복무형태가 변경된 경우, 해당 사용자의 알림 이력 초기화 + if (oldDate !== dateArg || oldType !== typeArg) { + logger.info(`Enlistment data changed for ${targetUserId}. Resetting their notification history.`); + notificationHistory[targetUserId] = []; + notificationHistoryModified = true; + } + } else { // 신규 사용자 정보 추가 + const newData = { id: targetUserId, date: dateArg, type: typeArg }; + binaryInsert(ipdaeDatas, newData); + // 신규 사용자의 알림 이력도 초기화 (또는 빈 배열 확인) + if (!notificationHistory[targetUserId]) { + notificationHistory[targetUserId] = []; + notificationHistoryModified = true; + } else if (notificationHistory[targetUserId].length > 0) { // 혹시 모를 기존 이력 초기화 + notificationHistory[targetUserId] = []; + notificationHistoryModified = true; + } + } + + try { + // 1. ipdaeData.json 저장 + await fs.writeFile(ipdaeDataDirectory, JSON.stringify(ipdaeDatas, null, 4), 'utf8'); + logger.info(`ipdaeData.json successfully updated for user ${targetUserId}. Action: ${isSaved ? 'Updated' : 'Added'}.`); + + // 2. notificationHistory.json 저장 (변경되었을 경우) + if (notificationHistoryModified) { + await fs.writeFile(notificationHistoryPath, JSON.stringify(notificationHistory, null, 4), 'utf8'); + logger.info(`notificationHistory.json saved due to changes for user ${targetUserId}.`); + } + + const finalDataIndex = binarySearch(ipdaeDatas, targetUserId); + const finalData = ipdaeDatas[finalDataIndex]; + return new EmbedBuilder() + .setTitle(startDateEmbedTitle) + .addFields( + { name: '입대일', value: dateFormatter(finalData.date) }, + { name: '복무 형태', value: finalData.type } + ) + .setColor('#3BB143'); + } catch (e) { + logger.error(`Failed to save data for user ${targetUserId}: ${e.message}`); + return new EmbedBuilder() + .setTitle('데이터 저장/수정 중 오류가 발생했습니다.') + .addFields({ name: '에러 메시지', value: e.message }) + .setColor('#F44336'); + } +} + +// getEndDateEmbed 함수 (최종 수정된 계급 로직 포함) +function getEndDateEmbed(targetUserId, targetUserName, decimal, usedFullInfo) { + let months = 0; + 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 startText = '입대일'; + let endText = '전역일'; + + switch (ipdaeData.type) { + case '육군': months = 18; break; + case '해군': months = 20; break; + case '공군': months = 21; break; + case '해병대': months = 18; break; + case '사회복무요원': + months = 21; + startText = '소집일'; + endText = '소집해제일'; + break; + default: + logger.error(`Unknown service type "${ipdaeData.type}" for user ${targetUserId}`); + return new EmbedBuilder().setTitle("오류").setDescription("알 수 없는 복무 형태입니다.").setColor("#F44336"); + } + + const startDate = ipdaeData.date; + const endDate = addMonthsToDate(startDate, months); + + const now = new Date(); + + const currentProgress = parseFloat(calculateProgress(startDate, endDate, now)); + const totalDays = calculateDateDifference(startDate, endDate) + 1; + + const todayFormatted = formatDate(now); + let daysServed = calculateDateDifference(startDate, todayFormatted) + 1; + if (new Date(todayFormatted) < new Date(startDate)) { + daysServed = 0; + } + daysServed = Math.min(daysServed, totalDays); + + const remainingDays = totalDays - daysServed; + const trueRemainingDays = Math.max(0, remainingDays); + + const [sYearStr, sMonthStr, sDayStr] = startDate.split('-'); + const currentYearForRank = now.getFullYear(); + const currentMonthForRank = now.getMonth() + 1; + + let pastMonths = calculateMonthDifference(sYearStr, sMonthStr, String(currentYearForRank), String(currentMonthForRank)); + + if (sDayStr === '01') { + pastMonths += 1; + } + + let militaryRank; + + if (pastMonths <= 2) { + militaryRank = `이병 ${pastMonths + 1}호봉`; + } else if (pastMonths <= 8) { + militaryRank = `일병 ${pastMonths - 2}호봉`; + } else if (pastMonths <= 14) { + militaryRank = `상병 ${pastMonths - 8}호봉`; + } else if (trueRemainingDays <= 0) { + militaryRank = '민간인'; + } else { + militaryRank = `병장 ${pastMonths - 14}호봉`; + } + + const progressBarText = createProgressBar(currentProgress, 10, typeof decimal === 'number' ? decimal : parseInt(decimal) || 4); + + if (!usedFullInfo) { + return new EmbedBuilder() + .setTitle(`${userNameForEmbed}의 ${endText}`) + .addFields( + { name: '현재 진행도', value: `${progressBarText}\n${militaryRank}${addSpace(Math.max(0, 14 - militaryRank.length))}D-${trueRemainingDays}` }, + { 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(trueRemainingDays) }, + { name: '현재 계급', value: militaryRank } + ) + .setColor('#007FFF'); + } +} + +async function checkAndSendCelebrationMessages() { + logger.info('Starting celebration check...'); + if (!celebrationConfig || !celebrationConfig.milestones || !celebrationConfig.channelTopicKeyword) { + logger.warn('Celebration config is missing or incomplete. Skipping celebration check.'); + return; + } + + let notificationHistoryModifiedThisRun = false; // 이번 실행에서 notificationHistory 변경 여부 + const today = new Date(); + + for (const userData of ipdaeDatas) { + if (!userData.id || !userData.date || !userData.type) { + logger.warn(`Skipping user data due to missing id, date, or type: ${JSON.stringify(userData)}`); + continue; + } + + let months = 0; + switch (userData.type) { + case '육군': months = 18; break; + case '해군': months = 20; break; + case '공군': months = 21; break; + case '해병대': months = 18; break; + case '사회복무요원': months = 21; break; + default: + logger.warn(`Unknown service type "${userData.type}" for user ${userData.id}. Skipping.`); + continue; + } + + const endDateString = addMonthsToDate(userData.date, months); + const remainingDays = calculateDaysFromNow(endDateString, today); + const userHistory = notificationHistory[userData.id] || []; // 없으면 빈 배열 + + for (const milestone of celebrationConfig.milestones) { + const currentMilestoneMentionUser = typeof milestone.mentionUser === 'boolean' ? milestone.mentionUser : true; + const currentMilestoneMentionEveryone = typeof milestone.mentionEveryone === 'boolean' ? milestone.mentionEveryone : false; + + if (remainingDays === milestone.days) { + if (!userHistory.includes(milestone.days)) { // 이력 확인 + let userToNotify; + try { + userToNotify = await client.users.fetch(userData.id); + } catch (fetchError) { + logger.warn(`Could not fetch user ${userData.id} for celebration: ${fetchError.message}. Skipping.`); + continue; + } + if (!userToNotify) continue; + + for (const guild of client.guilds.cache.values()) { + let member; + try { + member = guild.members.cache.get(userData.id) || await guild.members.fetch(userData.id).catch(() => null); + } catch { member = null; } + + if (member) { + const displayName = member.displayName || userToNotify.username; + const targetChannels = guild.channels.cache.filter(ch => + ch.isTextBased() && + ch.topic && + ch.topic.includes(celebrationConfig.channelTopicKeyword) && + guild.members.me && + guild.members.me.permissionsIn(ch).has(PermissionsBitField.Flags.SendMessages) + ); + + targetChannels.forEach(async channel => { + try { + let messagePrefix = ""; + let allowEveryoneMentionForSend = false; + let allowUserMentionForSend = false; + + if (currentMilestoneMentionEveryone) { + if (guild.members.me.permissionsIn(channel).has(PermissionsBitField.Flags.MentionEveryone)) { + messagePrefix += "@everyone "; + allowEveryoneMentionForSend = true; + } else { + logger.warn(`Bot lacks MentionEveryone permission in #${channel.name} in ${guild.name}. Skipping @everyone mention for ${displayName}.`); + } + } + + if (currentMilestoneMentionUser) { + messagePrefix += `<@${userData.id}> `; + allowUserMentionForSend = true; + } + + const baseMessageText = milestone.message.replace(/{userName}/g, displayName); + const finalMessageToSend = messagePrefix + baseMessageText; + + await channel.send({ + content: finalMessageToSend, + allowedMentions: { + parse: allowEveryoneMentionForSend ? ['everyone'] : [], + users: allowUserMentionForSend ? [userData.id] : [], + roles: [] + } + }); + + logger.info(`Sent ${milestone.days}-day celebration for ${displayName} (${userData.id}) to #${channel.name} in ${guild.name}. Mentions: User=${allowUserMentionForSend}, Everyone=${allowEveryoneMentionForSend}.`); + + // 알림 이력 기록 + if (!notificationHistory[userData.id]) { // 혹시 모를 경우 대비하여 배열 초기화 + notificationHistory[userData.id] = []; + } + if (!notificationHistory[userData.id].includes(milestone.days)) { + notificationHistory[userData.id].push(milestone.days); + notificationHistoryModifiedThisRun = true; + } + } catch (sendError) { + logger.error(`Failed to send celebration message to #${channel.name} in ${guild.name} for ${displayName}: ${sendError.message}`); + } + }); + } + } + } + } + } + } + + if (notificationHistoryModifiedThisRun) { + try { + await fs.writeFile(notificationHistoryPath, JSON.stringify(notificationHistory, null, 4), 'utf8'); + logger.info('notificationHistory.json updated with new celebration notification statuses.'); + } catch (e) { + logger.error(`Failed to save notificationHistory.json after celebration checks: ${e.message}`); + } + } + logger.info('Celebration check finished.'); +} + +function scheduleDailyCelebrationCheck() { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); + tomorrow.setHours(0, 0, 5, 0); // 다음 날 00:00:05 KST (서버 시간 무관, UTC 기준 다음날 00:00:05에 실행될 수 있도록 시간 계산) + + 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(async () => { + logger.info('Executing scheduled daily celebration check (first run after startup schedule)...'); + await checkAndSendCelebrationMessages(); + setInterval(async () => { + logger.info('Executing scheduled daily celebration check (interval)...'); + await checkAndSendCelebrationMessages(); + }, 24 * 60 * 60 * 1000); // 24시간 + }, timeToFirstCheck); +} + +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); + const diffTime = inputDate.getTime() - today.getTime(); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); +} + +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); + const diffTime = Math.abs(d2.getTime() - d1.getTime()); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); +} + +function calculateProgress(startDateStr, endDateStr, now = new Date()) { + const start = new Date(startDateStr).getTime(); + const end = new Date(endDateStr).getTime(); + const currentTime = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + + if (start >= end) return '100.0000000'; + if (currentTime <= start) return '0.0000000'; + if (currentTime >= end) return '100.0000000'; + + const totalDuration = end - start; + const elapsedDuration = currentTime - start; + + let progress = (elapsedDuration / totalDuration) * 100; + progress = Math.max(0, Math.min(100, progress)); + return progress.toFixed(7); +} + +function createProgressBar(progress, totalLength, toFixedValue) { + progress = Math.max(0, Math.min(100, parseFloat(progress))); + toFixedValue = parseInt(toFixedValue); + if (isNaN(toFixedValue) || toFixedValue < 0) toFixedValue = 4; + + const filledLength = Math.round((progress / 100) * totalLength); + const unfilledLength = totalLength - filledLength; + const filledBar = '█'.repeat(filledLength); + const unfilledBar = '░'.repeat(unfilledLength); + return `[${filledBar}${unfilledBar}] ${progress.toFixed(toFixedValue)}%`; +} + +function getNameById(id, guild = null) { + try { + 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; // 서버별 별명 반환 + // 캐시에 없으면 fetch 시도 (비동기 처리는 이 함수 구조상 어려움, 필요시 async/await로 변경) + // guild.members.fetch(id).then(m => m.displayName).catch(() => user.username); // 비동기 호출 필요시 + } + + // 특정 서버 컨텍스트 없거나, 해당 서버에 멤버 정보 없으면 + // 봇이 속한 아무 서버에서라도 별명 찾아보기 (첫번째 찾은 별명 사용) + for (const g of client.guilds.cache.values()){ + const memberInAnyGuild = g.members.cache.get(id); + if(memberInAnyGuild) return memberInAnyGuild.displayName; + } + return user.username; // 최후의 수단: 기본 사용자 이름 + } catch (e) { + logger.warn(`Error in getNameById for ID ${id}: ${e.message}`); + return `Unknown User (${id})`; // 오류 시 기본값 + } +} + +function calculateMonthDifference(startDateYear, startDateMonth, endDateYear, endDateMonth) { + startDateYear = Number(startDateYear); + startDateMonth = Number(startDateMonth); + endDateYear = Number(endDateYear); + endDateMonth = Number(endDateMonth); + + const yearDifference = endDateYear - startDateYear; + const monthDifference = endDateMonth - startDateMonth; + + return (yearDifference * 12) + monthDifference; +} + +function beautifyNumber(number) { + return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +function addSpace(num) { + if (typeof num !== 'number' || num < 0) return ''; + return ' '.repeat(num); +} + +function generateRandomNumber(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + if (min > max) [min, max] = [max, min]; + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +// 원본 parseArgs 함수 (사용자 제공) +function parseArgs(cmdStr) { + const re_next_arg = /^\s*((?:(?:"(?:\\.|[^"])*")|(?:'[^']*')|\\.|\S)+)\s*(.*)$/; + let next_arg = ['', '', cmdStr]; + const args = []; + while ((next_arg = re_next_arg.exec(next_arg[2]))) { + let quoted_arg = next_arg[1]; + let unquoted_arg = ''; + while (quoted_arg.length > 0) { + if (/^"/.test(quoted_arg)) { + const quoted_part = /^"((?:\\.|[^"])*)"(.*)$/.exec(quoted_arg); + unquoted_arg += quoted_part[1].replace(/\\(.)/g, '$1'); + quoted_arg = quoted_part[2]; + } else if (/^'/.test(quoted_arg)) { + const quoted_part = /^'([^']*)'(.*)$/.exec(quoted_arg); + unquoted_arg += quoted_part[1]; + quoted_arg = quoted_part[2]; + } else if (/^\\/.test(quoted_arg)) { + unquoted_arg += quoted_arg[1]; + quoted_arg = quoted_arg.substring(2); + } else { + unquoted_arg += quoted_arg[0]; + quoted_arg = quoted_arg.substring(1); + } + } + args.push(unquoted_arg); + } + return args; +} + +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}`; +} + +logger.info('Attempting to log in with token...'); + +//메인봇 토큰 +client.login('MTI4MjY1Mzg3NDk3NjcyMjk0NA.Gdp9Oc.1af98FXAiV911qFL5a5hHI0Mmf95qmT7ES9FhQ'); + +//테스트봇 토큰 +//client.login('MTMzNTE1MzgwNjg1NzQ2OTk2Mw.GZPzlI.YzJsJZgPD1Ac-Ddnkw-c9C1SI1QL2xz2IlZ7Z4'); diff --git a/modules/colorfulLogger.js b/modules/colorfulLogger.js new file mode 100755 index 0000000..73b4377 --- /dev/null +++ b/modules/colorfulLogger.js @@ -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) + + } +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100755 index 0000000..afcedd0 --- /dev/null +++ b/package-lock.json @@ -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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..2d07ef9 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/src/commands/military/celebration.js b/src/commands/military/celebration.js new file mode 100644 index 0000000..8410055 --- /dev/null +++ b/src/commands/military/celebration.js @@ -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 }; \ No newline at end of file diff --git a/src/commands/military/getDischargeInfo.js b/src/commands/military/getDischargeInfo.js new file mode 100644 index 0000000..2d856a2 --- /dev/null +++ b/src/commands/military/getDischargeInfo.js @@ -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 +}; \ No newline at end of file diff --git a/src/commands/military/setEnlistmentDate.js b/src/commands/military/setEnlistmentDate.js new file mode 100644 index 0000000..5727266 --- /dev/null +++ b/src/commands/military/setEnlistmentDate.js @@ -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 +}; \ No newline at end of file diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js new file mode 100644 index 0000000..5a2ee2c --- /dev/null +++ b/src/events/interactionCreate.js @@ -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); + } + }, +}; \ No newline at end of file diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js new file mode 100644 index 0000000..a40bbde --- /dev/null +++ b/src/events/messageCreate.js @@ -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); + } + }, +}; \ No newline at end of file diff --git a/src/events/ready.js b/src/events/ready.js new file mode 100644 index 0000000..bfa57fc --- /dev/null +++ b/src/events/ready.js @@ -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); + } + }, +}; \ No newline at end of file diff --git a/src/handlers/buttonHandler.js b/src/handlers/buttonHandler.js new file mode 100644 index 0000000..ee0727d --- /dev/null +++ b/src/handlers/buttonHandler.js @@ -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 }; \ No newline at end of file diff --git a/src/handlers/commandHandler.js b/src/handlers/commandHandler.js new file mode 100644 index 0000000..089d8f1 --- /dev/null +++ b/src/handlers/commandHandler.js @@ -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 }; diff --git a/src/handlers/koreanbotsHandler.js b/src/handlers/koreanbotsHandler.js new file mode 100644 index 0000000..3897899 --- /dev/null +++ b/src/handlers/koreanbotsHandler.js @@ -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 }; \ No newline at end of file diff --git a/src/handlers/processHandlers.js b/src/handlers/processHandlers.js new file mode 100644 index 0000000..751cdbc --- /dev/null +++ b/src/handlers/processHandlers.js @@ -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 }; \ No newline at end of file diff --git a/src/handlers/rpcHandler.js b/src/handlers/rpcHandler.js new file mode 100644 index 0000000..65320a8 --- /dev/null +++ b/src/handlers/rpcHandler.js @@ -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 }; diff --git a/src/handlers/slashCommandHandler.js b/src/handlers/slashCommandHandler.js new file mode 100644 index 0000000..c4ed1e0 --- /dev/null +++ b/src/handlers/slashCommandHandler.js @@ -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 }; \ No newline at end of file diff --git a/src/utils/dateUtils.js b/src/utils/dateUtils.js new file mode 100644 index 0000000..c743fd3 --- /dev/null +++ b/src/utils/dateUtils.js @@ -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 +}; \ No newline at end of file diff --git a/src/utils/discordUtils.js b/src/utils/discordUtils.js new file mode 100644 index 0000000..235ab15 --- /dev/null +++ b/src/utils/discordUtils.js @@ -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 +}; \ No newline at end of file diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..a1d8824 --- /dev/null +++ b/src/utils/helpers.js @@ -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 +}; \ No newline at end of file diff --git a/utils/errorHandler.js b/utils/errorHandler.js new file mode 100644 index 0000000..6d18f68 --- /dev/null +++ b/utils/errorHandler.js @@ -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, +};