Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/bot/src/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { Sentry } from './lib/sentry';
import {
getAttendanceChecker,
getCurationCrawler,
getDeadlineReminder,
getFineReminder,
getPollReminder,
getRoundReporter,
getRssPoller,
getWeeklyRanking,
Expand Down Expand Up @@ -203,6 +205,67 @@ export function createBotApiServer(): Express {
}
});

app.post('/api/trigger/poll-reminder-dm', authMiddleware, triggerLimiter, async (req, res) => {
try {
const pollReminder = getPollReminder();

if (pollReminder.isReminding()) {
return res.status(409).json({ error: '투표 리마인더가 이미 실행 중입니다' });
}

const { pollId, discordId } = req.body || {};

if (!pollId) {
return res.status(400).json({ error: 'pollId가 필요합니다' });
}

const result = await pollReminder.sendRemindersForPoll(pollId, discordId);

const serializedResult = {
...result,
timestamp: result.timestamp instanceof Date
? result.timestamp.toISOString()
: result.timestamp,
};

res.json({ success: true, result: serializedResult });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '🌐 [API] 투표 리마인더 에러');
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

app.post('/api/trigger/deadline-reminder', authMiddleware, triggerLimiter, async (req, res) => {
try {
const deadlineReminder = getDeadlineReminder();

if (deadlineReminder.isSending()) {
return res.status(409).json({ error: '마감 리마인더가 이미 실행 중입니다' });
}

const { dDay } = req.body || {};

// dDay가 지정되면 수동 발송, 아니면 자동(오늘 날짜 기준)
const result = typeof dDay === 'number'
? await deadlineReminder.sendManual(dDay)
: await deadlineReminder.sendReminders();

const serializedResult = {
...result,
timestamp: result.timestamp instanceof Date
? result.timestamp.toISOString()
: result.timestamp,
};

res.json({ success: true, result: serializedResult });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '🌐 [API] 마감 리마인더 에러');
res.status(500).json({ error: '내부 오류가 발생했습니다' });
}
});

return app;
}

Expand Down
57 changes: 57 additions & 0 deletions packages/bot/src/handlers/dm-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,63 @@ export async function sendFineReminder(
}
}

/**
* Send poll reminder DM to a user
* 투표 마감 전 미참여자에게 리마인더 발송
*/
export async function sendPollReminderDM(
client: Client,
discordId: string,
pollQuestion: string,
expiresAt: Date,
postId: string,
): Promise<boolean> {
try {
const user = await client.users.fetch(discordId);
if (!user) {
logger.error({ discordId }, '💬 [DM] 유저를 찾을 수 없음');
return false;
}

const expiresHour = expiresAt.toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});

const webUrl = process.env.WEB_URL || 'https://kusting-web.vercel.app';
const postUrl = `${webUrl}/board/${postId}`;

const message = [
`📊 **투표 참여 요청**`,
``,
`"${pollQuestion}" 투표가 내일 ${expiresHour}에 마감됩니다!`,
`아직 참여하지 않으셨으니 투표해주세요 🙏`,
].join('\n');

const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setLabel('📊 투표하러 가기')
.setStyle(ButtonStyle.Link)
.setURL(postUrl),
);

await user.send({
content: message,
components: [row],
});

logger.info({ discordId, pollQuestion }, '💬 [DM] 투표 리마인더 발송 완료');
return true;
} catch (error) {
logger.error({ discordId, error: serializeError(error) }, '💬 [DM] 투표 리마인더 발송 실패');
return false;
}
}

/**
* Setup DM handler for the bot client
* MessageContent Intent 없이 버튼 인터랙션으로 동작
Expand Down
4 changes: 4 additions & 0 deletions packages/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { registerAllJobs } from './scheduler-registry';

import { setupDMHandler } from './handlers/dm-handler';
import { initNotificationService } from './services/notification.service';
import { getPollReminder } from './schedulers/poll-reminder';
import { startBotApiServer } from './api-server';
import logger, { serializeError } from './lib/logger';
import { Sentry } from './lib/sentry';
Expand All @@ -35,6 +36,9 @@ async function main(): Promise<void> {
initNotificationService(client);
logger.debug('📢 [알림] 알림 서비스 초기화 완료');

// Initialize poll reminder with client (manual trigger only)
getPollReminder().setClient(client);

// Start pg-boss job queue and register all scheduled jobs
const boss = await startJobQueue(env.DATABASE_URL_DIRECT);
await registerAllJobs(boss, client);
Expand Down
15 changes: 13 additions & 2 deletions packages/bot/src/scheduler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getFineReminder } from './schedulers/fine-reminder';
import { getRoundReporter } from './schedulers/round-reporter';
import { getCurationCrawler } from './schedulers/curation-crawler';
import { getWeeklyRanking } from './schedulers/weekly-ranking';
import { getDeadlineReminder } from './schedulers/deadline-reminder';
import type { CrawledContent } from './services/curation.service';
import { getPostService } from './services/post.service';
import { getNotificationService } from './services/notification.service';
Expand All @@ -39,6 +40,7 @@ const JOB_DEFINITIONS = [
{ name: 'curation-crawl', cron: '0 23 * * *' }, // 4기 미사용
{ name: 'curation-share', cron: '5 10 * * *' }, // 4기 미사용
{ name: 'weekly-ranking', cron: '0 1 * * 0' }, // KST 일 10:00 (UTC 일 01:00)
{ name: 'deadline-reminder', cron: '0 23 * * *' }, // KST 매일 08:00 (UTC 23:00)
] as const;

/**
Expand All @@ -54,11 +56,13 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
const roundReporter = getRoundReporter();
const curationCrawler = getCurationCrawler();
const weeklyRanking = getWeeklyRanking();
const deadlineReminder = getDeadlineReminder();

fineReminder.setClient(client);
roundReporter.setClient(client);
curationCrawler.setClient(client);
weeklyRanking.setClient(client);
deadlineReminder.setClient(client);

// Set up RSS poller callback: new post → save to DB + send notification + grant score + update attendance
const postService = getPostService();
Expand Down Expand Up @@ -92,8 +96,8 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
if (result.isNew) {
logger.info({ member: member.name, title: item.title.slice(0, 80) }, '📡 [RSS] 새 글 DB 저장 완료');

// P0 #3: 출석 상태 업데이트 (제출 또는 지각)
if (currentRound) {
// P0 #3: 출석 상태 업데이트 (제출 또는 지각) — active 유저만
if (currentRound && member.status === 'active') {
// 회차 기간 내 제출 여부 판단
// 마감: graceEndDate(월요일) 00:00 KST까지 정상 출석, 이후 지각
const roundEndDate = new Date(`${currentRound.graceEndDate}T00:00:00.000+09:00`);
Expand Down Expand Up @@ -301,6 +305,13 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
await weeklyRanking.sendWeeklyRanking();
});


await boss.createQueue('deadline-reminder');

await boss.work('deadline-reminder', { batchSize: 1 }, async () => {
await deadlineReminder.sendReminders();
});

// Wait for queues to be created in the database
await new Promise(resolve => setTimeout(resolve, 500));

Expand Down
Loading
Loading