const fs = require('fs'); const path = require('path'); const Database = require('better-sqlite3'); const FALLBACK_EMOJIS = ['🐱', '🐰', 'πŸ₯', '🦊', 'πŸ€', '🌸', '✨', 'πŸŽ‰', 'πŸ’«', '🌈', 'πŸ“', 'πŸŒ™']; const DISPLAY_TIME_ZONE = 'Asia/Seoul'; function ensureDirectory(filePath) { const directory = path.dirname(filePath); fs.mkdirSync(directory, { recursive: true }); } function createDatabase(databasePath) { ensureDirectory(databasePath); const db = new Database(databasePath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.pragma('busy_timeout = 5000'); db.exec(` CREATE TABLE IF NOT EXISTS state ( id INTEGER PRIMARY KEY CHECK (id = 1), last_check_at TEXT NOT NULL, total_checks INTEGER NOT NULL DEFAULT 0, trigger_executed_for_check_at TEXT ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, emoji TEXT NOT NULL, created_at TEXT NOT NULL, accepted INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS trigger_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, executed_at TEXT NOT NULL, script_path TEXT NOT NULL, success INTEGER NOT NULL DEFAULT 0, output TEXT ); CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC); `); const existingState = db.prepare('SELECT id FROM state WHERE id = 1').get(); if (!existingState) { const now = new Date().toISOString(); db.prepare(` INSERT INTO state (id, last_check_at, total_checks, trigger_executed_for_check_at) VALUES (1, ?, 0, NULL) `).run(now); } return db; } function randomEmoji() { return FALLBACK_EMOJIS[Math.floor(Math.random() * FALLBACK_EMOJIS.length)]; } function getStateRow(db) { return db.prepare('SELECT * FROM state WHERE id = 1').get(); } function getRecentEvents(db, limit) { return db .prepare(` SELECT emoji, created_at, accepted FROM events ORDER BY id DESC LIMIT ? `) .all(limit); } function daysBetween(fromDate, toDate) { const start = new Date(fromDate); const end = new Date(toDate); const diffMs = end.getTime() - start.getTime(); return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24))); } function thresholdMilliseconds(config) { if (config.threshold_seconds) { return config.threshold_seconds * 1000; } return config.threshold_days * 24 * 60 * 60 * 1000; } function deadlineFor(lastCheckAt, config) { return new Date(new Date(lastCheckAt).getTime() + thresholdMilliseconds(config)); } function isExpired(state, config, now = new Date()) { return now.getTime() >= deadlineFor(state.last_check_at, config).getTime(); } function buildStatus(db, config) { const state = getStateRow(db); const now = new Date(); const lastCheckAt = new Date(state.last_check_at); const elapsedDays = daysBetween(state.last_check_at, now.toISOString()); const deadlineAt = deadlineFor(lastCheckAt, config); const remainingMs = Math.max(0, deadlineAt.getTime() - now.getTime()); const remainingDays = Math.floor(remainingMs / (24 * 60 * 60 * 1000)); return { last_check_at: state.last_check_at, deadline_at: deadlineAt.toISOString(), expired: remainingMs <= 0, last_check_display: formatDateTime(lastCheckAt), elapsed_days: elapsedDays, remaining_days: remainingDays, total_checks: state.total_checks, threshold_days: config.threshold_days, threshold_seconds: config.threshold_seconds, cooldown_seconds: config.cooldown_seconds, collection_path: config.collection_path, recent_traces: getRecentEvents(db, config.recent_traces_limit).map((event) => ({ emoji: event.emoji, time: formatTime(new Date(event.created_at)), created_at: event.created_at, accepted: Boolean(event.accepted), })), }; } function formatDateTime(date) { const parts = getDateTimeParts(date); const year = parts.year; const month = parts.month; const day = parts.day; const hours = parts.hour; const minutes = parts.minute; return `${year}-${month}-${day} ${hours}:${minutes}`; } function formatTime(date) { const parts = getDateTimeParts(date); const hours = parts.hour; const minutes = parts.minute; const seconds = parts.second; return `${hours}:${minutes}:${seconds}`; } function getDateTimeParts(date) { return Object.fromEntries( new Intl.DateTimeFormat('ko-KR', { timeZone: DISPLAY_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) .formatToParts(date) .filter((part) => part.type !== 'literal') .map((part) => [part.type, part.value]) ); } function recordCheck(db, config, requestedEmoji) { const emoji = typeof requestedEmoji === 'string' && requestedEmoji.trim() ? requestedEmoji.trim().slice(0, 16) : randomEmoji(); const insertEvent = db.prepare(` INSERT INTO events (emoji, created_at, accepted) VALUES (?, ?, ?) `); const updateState = db.prepare(` UPDATE state SET last_check_at = ?, total_checks = total_checks + 1, trigger_executed_for_check_at = NULL WHERE id = 1 `); let accepted = false; let expired = false; const transaction = db.transaction(() => { const now = new Date(); const nowIso = now.toISOString(); const state = getStateRow(db); if (isExpired(state, config, now)) { expired = true; return; } const lastAcceptedAt = new Date(state.last_check_at); const secondsSinceLastCheck = (now.getTime() - lastAcceptedAt.getTime()) / 1000; accepted = secondsSinceLastCheck >= config.cooldown_seconds; insertEvent.run(emoji, nowIso, accepted ? 1 : 0); if (accepted) { updateState.run(nowIso); } }); transaction(); if (expired) { return { accepted: false, expired: true, emoji, message: '이미 μ‹œκ°„μ΄ μ§€λ‚¬μ–΄μš”.', status: buildStatus(db, config), }; } const message = accepted ? `${emoji} 방금 ν™•μΈν–ˆμ–΄μš”!` : `${emoji} μž μ‹œ ν›„ λ‹€μ‹œ λˆŒλŸ¬μ£Όμ„Έμš”.`; return { accepted, expired: false, emoji, message, status: buildStatus(db, config), }; } function shouldRunTrigger(db, config) { const state = getStateRow(db); if (!isExpired(state, config)) { return false; } if (state.trigger_executed_for_check_at === state.last_check_at) { return false; } return true; } function markTriggerExecuted(db, scriptPath, success, output) { const nowIso = new Date().toISOString(); const state = getStateRow(db); const insertRun = db.prepare(` INSERT INTO trigger_runs (executed_at, script_path, success, output) VALUES (?, ?, ?, ?) `); const updateState = db.prepare(` UPDATE state SET trigger_executed_for_check_at = ? WHERE id = 1 `); const transaction = db.transaction(() => { insertRun.run(nowIso, scriptPath, success ? 1 : 0, output ?? ''); updateState.run(state.last_check_at); }); transaction(); } function resetKeeper(db) { const nowIso = new Date().toISOString(); const resetState = db.prepare(` UPDATE state SET last_check_at = ?, total_checks = 0, trigger_executed_for_check_at = NULL WHERE id = 1 `); const deleteEvents = db.prepare('DELETE FROM events'); const deleteTriggerRuns = db.prepare('DELETE FROM trigger_runs'); const transaction = db.transaction(() => { resetState.run(nowIso); deleteEvents.run(); deleteTriggerRuns.run(); }); transaction(); } module.exports = { FALLBACK_EMOJIS, createDatabase, buildStatus, recordCheck, shouldRunTrigger, markTriggerExecuted, resetKeeper, };