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
};