package db import ( "database/sql" "fmt" "log/slog" "os" "path/filepath" "sync" _ "modernc.org/sqlite" ) var ( mu sync.Mutex globalDB *sql.DB ) // Init opens (or creates) the SQLite database and runs migrations. func Init(dataDir string) error { mu.Lock() defer mu.Unlock() if globalDB != nil { return nil } if err := os.MkdirAll(dataDir, 0o755); err != nil { return fmt.Errorf("create data dir: %w", err) } dbPath := filepath.Join(dataDir, "gogobee.db") d, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)") if err != nil { return fmt.Errorf("open database: %w", err) } d.SetMaxOpenConns(1) // SQLite is single-writer if err := runMigrations(d); err != nil { return fmt.Errorf("run migrations: %w", err) } globalDB = d slog.Info("database initialized", "path", dbPath) return nil } // Get returns the global database handle. Panics if Init was not called. func Get() *sql.DB { if globalDB == nil { panic("db.Get() called before db.Init()") } return globalDB } func runMigrations(d *sql.DB) error { _, err := d.Exec(schema) return err } // JobCompleted checks if a scheduled job has already completed for the given date key. // Use date "2006-01-02" for daily jobs, or "2006-W01" style for weekly jobs. func JobCompleted(jobName, dateKey string) bool { var completed int err := Get().QueryRow( `SELECT completed FROM daily_prefetch WHERE job_name = ? AND date = ?`, jobName, dateKey, ).Scan(&completed) return err == nil && completed == 1 } // MarkJobCompleted marks a scheduled job as completed for the given date key. func MarkJobCompleted(jobName, dateKey string) { _, err := Get().Exec( `INSERT INTO daily_prefetch (job_name, date, completed) VALUES (?, ?, 1) ON CONFLICT(job_name, date) DO UPDATE SET completed = 1`, jobName, dateKey, ) if err != nil { slog.Error("mark job completed", "job", jobName, "date", dateKey, "err", err) } } const schema = ` -- Users & XP CREATE TABLE IF NOT EXISTS users ( user_id TEXT PRIMARY KEY, display_name TEXT DEFAULT '', xp INTEGER DEFAULT 0, level INTEGER DEFAULT 0, last_xp_at INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS user_stats ( user_id TEXT PRIMARY KEY, total_messages INTEGER DEFAULT 0, total_words INTEGER DEFAULT 0, total_chars INTEGER DEFAULT 0, total_links INTEGER DEFAULT 0, total_images INTEGER DEFAULT 0, total_questions INTEGER DEFAULT 0, total_exclamations INTEGER DEFAULT 0, total_emojis INTEGER DEFAULT 0, night_messages INTEGER DEFAULT 0, morning_messages INTEGER DEFAULT 0, updated_at INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS xp_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, amount INTEGER NOT NULL, reason TEXT DEFAULT '', created_at INTEGER DEFAULT (unixepoch()) ); -- Reputation CREATE TABLE IF NOT EXISTS rep_cooldowns ( giver TEXT NOT NULL, receiver TEXT NOT NULL, last_given INTEGER DEFAULT (unixepoch()), PRIMARY KEY (giver, receiver) ); -- Reminders CREATE TABLE IF NOT EXISTS reminders ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, room_id TEXT NOT NULL, message TEXT NOT NULL, fire_at INTEGER NOT NULL, fired INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch()) ); CREATE INDEX IF NOT EXISTS idx_reminders_fire ON reminders(fired, fire_at); -- Daily activity / streaks CREATE TABLE IF NOT EXISTS daily_activity ( user_id TEXT NOT NULL, date TEXT NOT NULL, message_count INTEGER DEFAULT 0, PRIMARY KEY (user_id, date) ); CREATE TABLE IF NOT EXISTS daily_first ( room_id TEXT NOT NULL, date TEXT NOT NULL, user_id TEXT NOT NULL, timestamp INTEGER NOT NULL, PRIMARY KEY (room_id, date) ); -- Word of the Day CREATE TABLE IF NOT EXISTS wotd_log ( date TEXT PRIMARY KEY, word TEXT NOT NULL, definition TEXT NOT NULL, part_of_speech TEXT DEFAULT '', example TEXT DEFAULT '', posted INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS wotd_usage ( user_id TEXT NOT NULL, date TEXT NOT NULL, count INTEGER DEFAULT 0, rewarded INTEGER DEFAULT 0, PRIMARY KEY (user_id, date) ); -- Holidays CREATE TABLE IF NOT EXISTS holidays_log ( date TEXT PRIMARY KEY, data TEXT NOT NULL, posted INTEGER DEFAULT 0 ); -- Game releases CREATE TABLE IF NOT EXISTS releases_cache ( cache_key TEXT PRIMARY KEY, data TEXT NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS release_watchlist ( user_id TEXT NOT NULL, game_name TEXT NOT NULL, room_id TEXT NOT NULL, PRIMARY KEY (user_id, game_name) ); -- HLTB cache CREATE TABLE IF NOT EXISTS hltb_cache ( game_name TEXT PRIMARY KEY, data TEXT NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ); -- Achievements CREATE TABLE IF NOT EXISTS achievements ( user_id TEXT NOT NULL, achievement_id TEXT NOT NULL, unlocked_at INTEGER DEFAULT (unixepoch()), PRIMARY KEY (user_id, achievement_id) ); -- Quotes CREATE TABLE IF NOT EXISTS quotes ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id TEXT NOT NULL, user_id TEXT NOT NULL, quote_text TEXT NOT NULL, saved_by TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch()) ); -- Now Playing CREATE TABLE IF NOT EXISTS now_playing ( user_id TEXT PRIMARY KEY, track TEXT NOT NULL, updated_at INTEGER DEFAULT (unixepoch()) ); -- Backlog CREATE TABLE IF NOT EXISTS backlog ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, item TEXT NOT NULL, done INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch()) ); -- Predictions (stub/future) CREATE TABLE IF NOT EXISTS predictions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, room_id TEXT NOT NULL, prediction TEXT NOT NULL, outcome TEXT DEFAULT '', resolved INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch()) ); -- Keyword watches CREATE TABLE IF NOT EXISTS keyword_watches ( user_id TEXT NOT NULL, keyword TEXT NOT NULL, room_id TEXT NOT NULL, PRIMARY KEY (user_id, keyword) ); -- Scheduler config CREATE TABLE IF NOT EXISTS scheduler_config ( job_name TEXT PRIMARY KEY, enabled INTEGER DEFAULT 1, cron_expr TEXT NOT NULL, last_run TEXT DEFAULT '' ); -- Shade (stub) CREATE TABLE IF NOT EXISTS shade_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, target_user TEXT NOT NULL, message TEXT NOT NULL, room_id TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS shade_optout ( user_id TEXT PRIMARY KEY ); -- Birthdays CREATE TABLE IF NOT EXISTS birthdays ( user_id TEXT PRIMARY KEY, month INTEGER NOT NULL, day INTEGER NOT NULL, year INTEGER DEFAULT 0, timezone TEXT DEFAULT 'UTC' ); CREATE TABLE IF NOT EXISTS birthday_fired ( user_id TEXT NOT NULL, year INTEGER NOT NULL, PRIMARY KEY (user_id, year) ); -- Trivia CREATE TABLE IF NOT EXISTS trivia_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id TEXT NOT NULL, category INTEGER DEFAULT 0, difficulty TEXT DEFAULT 'medium', question TEXT NOT NULL, correct_answer TEXT NOT NULL, incorrect_answers TEXT NOT NULL, question_type TEXT DEFAULT 'multiple', thread_id TEXT DEFAULT '', started_at INTEGER DEFAULT (unixepoch()), ended INTEGER DEFAULT 0, winner_id TEXT DEFAULT '', winner_time_ms INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS trivia_scores ( user_id TEXT NOT NULL, room_id TEXT NOT NULL, correct INTEGER DEFAULT 0, wrong INTEGER DEFAULT 0, total_score INTEGER DEFAULT 0, fastest_ms INTEGER DEFAULT 0, PRIMARY KEY (user_id, room_id) ); -- LLM classifications CREATE TABLE IF NOT EXISTS llm_classifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, room_id TEXT NOT NULL, message_text TEXT NOT NULL, sentiment TEXT DEFAULT '', sentiment_score REAL DEFAULT 0, topics TEXT DEFAULT '[]', profanity INTEGER DEFAULT 0, profanity_severity INTEGER DEFAULT 0, insult_target TEXT DEFAULT '', wotd_used INTEGER DEFAULT 0, gratitude_target TEXT DEFAULT '', created_at INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS potty_mouth ( user_id TEXT PRIMARY KEY, count INTEGER DEFAULT 0, mild INTEGER DEFAULT 0, moderate INTEGER DEFAULT 0, scorching INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS insult_log ( user_id TEXT PRIMARY KEY, times_insulted INTEGER DEFAULT 0, times_insulting INTEGER DEFAULT 0 ); -- Stocks CREATE TABLE IF NOT EXISTS stocks_cache ( ticker TEXT PRIMARY KEY, data TEXT NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS stock_watchlist ( user_id TEXT NOT NULL, ticker TEXT NOT NULL, room_id TEXT NOT NULL, PRIMARY KEY (user_id, ticker) ); -- Command usage CREATE TABLE IF NOT EXISTS command_usage ( command TEXT NOT NULL, user_id TEXT NOT NULL, count INTEGER DEFAULT 0, PRIMARY KEY (command, user_id) ); -- Concerts CREATE TABLE IF NOT EXISTS concerts_cache ( artist TEXT PRIMARY KEY, data TEXT NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS concert_watchlist ( user_id TEXT NOT NULL, artist TEXT NOT NULL, room_id TEXT NOT NULL, PRIMARY KEY (user_id, artist) ); -- Anime CREATE TABLE IF NOT EXISTS anime_watchlist ( user_id TEXT NOT NULL, mal_id INTEGER NOT NULL, title TEXT NOT NULL, room_id TEXT NOT NULL, PRIMARY KEY (user_id, mal_id) ); CREATE TABLE IF NOT EXISTS anime_cache ( mal_id INTEGER PRIMARY KEY, data TEXT NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ); -- Movies CREATE TABLE IF NOT EXISTS movie_watchlist ( user_id TEXT NOT NULL, tmdb_id INTEGER NOT NULL, title TEXT NOT NULL, media_type TEXT DEFAULT 'movie', room_id TEXT NOT NULL, PRIMARY KEY (user_id, tmdb_id) ); CREATE TABLE IF NOT EXISTS movie_cache ( tmdb_id INTEGER PRIMARY KEY, data TEXT NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ); -- Countdowns CREATE TABLE IF NOT EXISTS countdowns ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, room_id TEXT NOT NULL, label TEXT NOT NULL, target_date TEXT NOT NULL, public INTEGER DEFAULT 1, completed INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch()) ); -- Presence CREATE TABLE IF NOT EXISTS presence ( user_id TEXT PRIMARY KEY, status TEXT DEFAULT 'online', message TEXT DEFAULT '', updated_at INTEGER DEFAULT (unixepoch()) ); -- Markov CREATE TABLE IF NOT EXISTS markov_corpus ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, text TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch()) ); CREATE INDEX IF NOT EXISTS idx_markov_user ON markov_corpus(user_id); -- Retro/game lookup cache CREATE TABLE IF NOT EXISTS retro_cache ( search_term TEXT PRIMARY KEY, data TEXT NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ); -- Urban Dictionary cache CREATE TABLE IF NOT EXISTS urban_cache ( term TEXT PRIMARY KEY, data TEXT NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ); -- Room milestones CREATE TABLE IF NOT EXISTS room_milestones ( room_id TEXT PRIMARY KEY, total_messages INTEGER DEFAULT 0, last_milestone INTEGER DEFAULT 0 ); -- Reaction log CREATE TABLE IF NOT EXISTS reaction_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id TEXT NOT NULL, event_id TEXT NOT NULL, sender TEXT NOT NULL, target_user TEXT NOT NULL, emoji TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch()) ); -- Sentiment stats (aggregated) CREATE TABLE IF NOT EXISTS sentiment_stats ( user_id TEXT PRIMARY KEY, positive INTEGER DEFAULT 0, negative INTEGER DEFAULT 0, neutral INTEGER DEFAULT 0, total_score REAL DEFAULT 0 ); -- Daily prefetch tracking CREATE TABLE IF NOT EXISTS daily_prefetch ( job_name TEXT NOT NULL, date TEXT NOT NULL, completed INTEGER DEFAULT 0, PRIMARY KEY (job_name, date) ); -- URL preview cache CREATE TABLE IF NOT EXISTS url_cache ( url TEXT PRIMARY KEY, title TEXT DEFAULT '', description TEXT DEFAULT '', cached_at INTEGER DEFAULT (unixepoch()) ); -- Rate limits CREATE TABLE IF NOT EXISTS rate_limits ( user_id TEXT NOT NULL, action TEXT NOT NULL, date TEXT NOT NULL, count INTEGER DEFAULT 0, PRIMARY KEY (user_id, action, date) ); ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. func SeedSchedulerDefaults(d *sql.DB) error { defaults := []struct { name string cron string }{ {"prefetch", "5 0 * * *"}, // 00:05 daily {"maintenance", "0 3 * * *"}, // 03:00 daily {"wotd", "0 8 * * *"}, // 08:00 daily {"holidays", "0 7 * * *"}, // 07:00 daily {"releases", "0 9 * * 1"}, // 09:00 Monday {"birthday_check", "0 6 * * *"}, // 06:00 daily {"anime_releases", "0 10 * * *"},// 10:00 daily {"movie_releases", "0 11 * * *"},// 11:00 daily {"concert_digest", "0 12 * * 0"},// 12:00 Sunday } stmt, err := d.Prepare(`INSERT OR IGNORE INTO scheduler_config (job_name, cron_expr) VALUES (?, ?)`) if err != nil { return err } defer stmt.Close() for _, def := range defaults { if _, err := stmt.Exec(def.name, def.cron); err != nil { return fmt.Errorf("seed scheduler %s: %w", def.name, err) } } return nil }