import fp from 'fastify-plugin'; import cron from 'node-cron'; import staticBots from '../config/bots.js'; import { syncAllSchedules } from '../services/meilisearch/index.js'; import { nowKST } from '../utils/date.js'; const REDIS_PREFIX = 'bot:status:'; const TIMEZONE = 'Asia/Seoul'; async function schedulerPlugin(fastify, opts) { const tasks = new Map(); let cachedBots = null; /** * DB에서 YouTube 봇 목록 조회 */ async function getYouTubeBotsFromDB() { const [rows] = await fastify.db.query( 'SELECT * FROM bot_youtube WHERE enabled = 1' ); return rows.map(row => ({ id: `youtube-${row.id}`, // DB ID를 문자열 형식으로 변환 dbId: row.id, type: 'youtube', channelId: row.channel_id, channelHandle: row.channel_handle, channelName: row.channel_name, bannerUrl: row.banner_url, cron: `*/${row.cron_interval} * * * *`, enabled: row.enabled === 1, titleFilters: row.title_filters ? (typeof row.title_filters === 'string' ? JSON.parse(row.title_filters) : row.title_filters) : [], defaultMemberIds: row.default_member_ids ? (typeof row.default_member_ids === 'string' ? JSON.parse(row.default_member_ids) : row.default_member_ids) : [], extractMembersFromDesc: row.extract_members_from_desc === 1, autoScheduleNext: row.auto_schedule_config ? (typeof row.auto_schedule_config === 'string' ? JSON.parse(row.auto_schedule_config) : row.auto_schedule_config) : null, })); } /** * DB에서 X 봇 목록 조회 */ async function getXBotsFromDB() { const [rows] = await fastify.db.query( 'SELECT * FROM bot_x WHERE enabled = 1' ); return rows.map(row => ({ id: `x-${row.id}`, dbId: row.id, type: 'x', username: row.username, displayName: row.display_name, avatarUrl: row.avatar_url, nitterUrl: process.env.NITTER_URL || 'http://nitter:8080', cron: `*/${row.cron_interval} * * * *`, enabled: row.enabled === 1, textFilters: row.text_filters ? (typeof row.text_filters === 'string' ? JSON.parse(row.text_filters) : row.text_filters) : [], })); } /** * 모든 봇 목록 가져오기 (정적 + DB) */ async function getAllBots(forceRefresh = false) { if (cachedBots && !forceRefresh) { return cachedBots; } const youtubeBots = await getYouTubeBotsFromDB(); const xBots = await getXBotsFromDB(); cachedBots = [...staticBots, ...youtubeBots, ...xBots]; return cachedBots; } /** * 봇 ID로 봇 찾기 */ async function findBot(botId) { const allBots = await getAllBots(); return allBots.find(b => b.id === botId); } /** * 봇 상태 Redis에 저장 */ async function updateStatus(botId, status) { const current = await getStatus(botId); const updated = { ...current, ...status, updatedAt: nowKST() }; await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated)); return updated; } /** * 봇 상태 Redis에서 조회 */ async function getStatus(botId) { const data = await fastify.redis.get(`${REDIS_PREFIX}${botId}`); if (data) { return JSON.parse(data); } return { status: 'stopped', lastCheckAt: null, lastAddedCount: 0, totalAdded: 0, lastSyncDuration: null, errorMessage: null, }; } /** * 봇 동기화 함수 가져오기 */ function getSyncFunction(bot) { if (bot.type === 'youtube') { return fastify.youtubeBot.syncNewVideos; } else if (bot.type === 'x') { return fastify.xBot.syncNewTweets; } else if (bot.type === 'meilisearch') { return async () => { const count = await syncAllSchedules(fastify.meilisearch, fastify.db); return { addedCount: count, total: count }; }; } return null; } /** * 동기화 결과 처리 */ async function handleSyncResult(botId, result, options = {}) { const { setRunningStatus = false } = options; const status = await getStatus(botId); const updateData = { lastCheckAt: nowKST(), totalAdded: (status.totalAdded || 0) + result.addedCount, }; if (setRunningStatus) { updateData.status = 'running'; updateData.errorMessage = null; } if (result.addedCount > 0) { updateData.lastAddedCount = result.addedCount; } await updateStatus(botId, updateData); return result.addedCount; } /** * 봇 시작 */ async function startBot(botId) { const bot = await findBot(botId); if (!bot) { throw new Error(`봇을 찾을 수 없습니다: ${botId}`); } // 기존 태스크가 있으면 정지 if (tasks.has(botId)) { tasks.get(botId).stop(); tasks.delete(botId); } const syncFn = getSyncFunction(bot); if (!syncFn) { throw new Error(`지원하지 않는 봇 타입: ${bot.type}`); } // cron 태스크 등록 (한국 시간 기준) const task = cron.schedule(bot.cron, async () => { fastify.log.info(`[${botId}] 동기화 시작`); try { const result = await syncFn(bot); const addedCount = await handleSyncResult(botId, result, { setRunningStatus: true }); fastify.log.info(`[${botId}] 동기화 완료: ${addedCount}개 추가`); } catch (err) { await updateStatus(botId, { status: 'error', lastCheckAt: nowKST(), errorMessage: err.message, }); fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`); } }, { timezone: TIMEZONE }); tasks.set(botId, task); await updateStatus(botId, { status: 'running' }); fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`); // 즉시 1회 실행 (meilisearch는 스케줄 시간에만 실행) if (bot.type !== 'meilisearch') { try { const result = await syncFn(bot); const addedCount = await handleSyncResult(botId, result); fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`); } catch (err) { fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`); } } } /** * 봇 정지 */ async function stopBot(botId) { if (tasks.has(botId)) { tasks.get(botId).stop(); tasks.delete(botId); } await updateStatus(botId, { status: 'stopped' }); fastify.log.info(`[${botId}] 스케줄 정지`); } /** * 모든 활성 봇 시작 */ async function startAll() { const allBots = await getAllBots(true); // DB에서 새로 로드 for (const bot of allBots) { if (bot.enabled) { try { await startBot(bot.id); } catch (err) { fastify.log.error(`[${bot.id}] 시작 실패: ${err.message}`); } } } } /** * 모든 봇 정지 */ async function stopAll() { for (const [botId, task] of tasks) { task.stop(); await updateStatus(botId, { status: 'stopped' }); } tasks.clear(); } /** * 봇 캐시 갱신 (봇 추가/수정/삭제 시 호출) */ function invalidateCache() { cachedBots = null; } // 데코레이터 등록 fastify.decorate('scheduler', { startBot, stopBot, startAll, stopAll, getStatus, getBots: (forceRefresh = false) => getAllBots(forceRefresh), invalidateCache, }); // 앱 종료 시 모든 봇 정지 fastify.addHook('onClose', async () => { await stopAll(); fastify.log.info('모든 봇 스케줄 정지'); }); } export default fp(schedulerPlugin, { name: 'scheduler', dependencies: ['db', 'redis', 'meilisearch', 'youtubeBot', 'xBot'], });