first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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