Files
deadman-switch/src/db.js
zenn 5f9f10f305 설정 보강 및 메시지 동작 정리
RESET_TOKEN을 .env에서 읽도록 바꾸고 한국 시간 표시와 시간대별 클릭 메시지 동작을 정리합니다.
2026-06-10 12:42:14 +09:00

305 lines
7.7 KiB
JavaScript

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