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');