diff --git a/index_bk.js b/index_bk.js deleted file mode 100755 index d5a30d3..0000000 --- a/index_bk.js +++ /dev/null @@ -1,1078 +0,0 @@ -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');