Files
gogobee/internal/db/db.go
prosolis 2c7c15b282 Add rate limiting, API response caching, and fix progress bar panic
- Rate limit !weather (5/day), !concerts (10/day), fix !translate default to 20/day
- Add generic api_cache table with CacheGet/CacheSet helpers
- Cache !weather (1h), !wiki, !define, !tv (24h), !upcoming movies, !anime season/upcoming (24h)
- Fix ProgressBar panic on negative Repeat count when XP exceeds level threshold
- All rate limits configurable via RATELIMIT_* env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:07:48 -07:00

577 lines
14 KiB
Go

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)
}
}
// CacheGet returns cached data for the given key if it exists and is within ttlSeconds.
// Returns empty string if not cached or expired.
func CacheGet(key string, ttlSeconds int) string {
d := Get()
var data string
err := d.QueryRow(
`SELECT data FROM api_cache WHERE cache_key = ? AND cached_at > unixepoch() - ?`,
key, ttlSeconds,
).Scan(&data)
if err != nil {
return ""
}
return data
}
// CacheSet stores data in the generic API cache.
func CacheSet(key, data string) {
d := Get()
_, err := d.Exec(
`INSERT INTO api_cache (cache_key, data, cached_at) VALUES (?, ?, unixepoch())
ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = unixepoch()`,
key, data, data,
)
if err != nil {
slog.Error("cache set", "key", key, "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)
);
-- Generic API response cache
CREATE TABLE IF NOT EXISTS api_cache (
cache_key TEXT PRIMARY KEY,
data TEXT NOT NULL,
cached_at INTEGER DEFAULT (unixepoch())
);
`
// 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
}