Files
JogyoGaori/index_bk.js
2025-08-07 01:18:49 +09:00

1079 lines
52 KiB
JavaScript
Executable File

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