mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Rewrite from TypeScript to Go
Complete rewrite of the Freebee Matrix bot as GogoBee using mautrix-go. - E2EE with goolm (pure Go, no CGo/libolm) and cross-signing bootstrap - 35+ plugins with dependency injection and ordered registration - SQLite storage via modernc.org/sqlite (no CGo) - Scheduler via robfig/cron for WOTD, holidays, birthdays, releases, etc. - Optional LLM integration (Ollama) for sentiment, profanity, roasts, vibes - Threaded trivia, three-tier profanity tracking, WOTD usage verification - Multi-country holiday support with deduplication - Quadratic XP curve, configurable bot display name, encrypted DMs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
204
internal/bot/client.go
Normal file
204
internal/bot/client.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gogobee/internal/util"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// DeviceInfo holds persisted device credentials.
|
||||
type DeviceInfo struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
DeviceID string `json:"device_id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// Config holds the bot's startup configuration.
|
||||
type Config struct {
|
||||
Homeserver string
|
||||
UserID string
|
||||
Password string
|
||||
DataDir string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// NewClient creates and configures a mautrix client with E2EE support.
|
||||
// The cryptohelper handles:
|
||||
// - Persistent crypto store in SQLite (device keys, sessions, cross-signing keys)
|
||||
// - Automatic cross-signing bootstrap (self-signs the device on first run)
|
||||
// - Automatic device trust via cross-signing (no manual verification needed)
|
||||
// - Megolm session sharing and key exchange
|
||||
// - Olm session management for device-to-device encryption
|
||||
//
|
||||
// This solves the TS version's device verification issues because:
|
||||
// 1. Crypto state persists across restarts (not in-memory like fake-indexeddb)
|
||||
// 2. Cross-signing makes other devices trust this bot automatically
|
||||
// 3. The bot trusts all users' devices by default (appropriate for a bot)
|
||||
// 4. No manual emoji/SAS verification needed
|
||||
func NewClient(cfg Config) (*mautrix.Client, error) {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
|
||||
devicePath := filepath.Join(cfg.DataDir, "device.json")
|
||||
|
||||
// Try to load existing device credentials
|
||||
device, err := loadDevice(devicePath)
|
||||
if err != nil {
|
||||
slog.Info("no existing device found, will login fresh")
|
||||
}
|
||||
|
||||
var client *mautrix.Client
|
||||
|
||||
if device != nil {
|
||||
// Validate existing token
|
||||
valid, _ := util.IsTokenValid(cfg.Homeserver, device.AccessToken)
|
||||
if valid {
|
||||
slog.Info("existing device credentials valid", "device_id", device.DeviceID)
|
||||
userID := id.UserID(device.UserID)
|
||||
client, err = mautrix.NewClient(cfg.Homeserver, userID, device.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client with existing token: %w", err)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(device.DeviceID)
|
||||
} else {
|
||||
slog.Warn("existing device credentials invalid, logging in again")
|
||||
device = nil
|
||||
}
|
||||
}
|
||||
|
||||
if device == nil {
|
||||
// Fresh login
|
||||
loginResp, err := util.LoginWithPassword(cfg.Homeserver, cfg.UserID, cfg.Password, cfg.DisplayName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login: %w", err)
|
||||
}
|
||||
|
||||
userID := id.UserID(loginResp.UserID)
|
||||
client, err = mautrix.NewClient(cfg.Homeserver, userID, loginResp.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(loginResp.DeviceID)
|
||||
|
||||
// Save device info
|
||||
device = &DeviceInfo{
|
||||
AccessToken: loginResp.AccessToken,
|
||||
DeviceID: loginResp.DeviceID,
|
||||
UserID: loginResp.UserID,
|
||||
}
|
||||
if err := saveDevice(devicePath, device); err != nil {
|
||||
slog.Warn("failed to save device info", "err", err)
|
||||
}
|
||||
|
||||
slog.Info("logged in successfully",
|
||||
"user_id", loginResp.UserID,
|
||||
"device_id", loginResp.DeviceID,
|
||||
)
|
||||
}
|
||||
|
||||
// Set up E2EE via cryptohelper — stores crypto state in its own SQLite DB,
|
||||
// separate from the main app database. Unlike the TS version which used an
|
||||
// in-memory fake-indexeddb store that was lost on restart (causing constant
|
||||
// re-verification), mautrix-go's cryptohelper persists everything in SQLite:
|
||||
// device keys, olm/megolm sessions, cross-signing keys, and device trust state.
|
||||
//
|
||||
// We pass just the raw file path — the cryptohelper wraps it in a file: URI
|
||||
// with _txlock=immediate internally (see cryptohelper.go line 82).
|
||||
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
|
||||
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
|
||||
// LoginAs enables the cryptohelper to re-login if the token expires,
|
||||
// and to bootstrap cross-signing on first run. Cross-signing means:
|
||||
// - The bot's master key signs its own device key
|
||||
// - Other users/devices that have verified the bot's master key
|
||||
// will automatically trust this device
|
||||
// - No interactive emoji/SAS verification needed
|
||||
ch.LoginAs = &mautrix.ReqLogin{
|
||||
Type: mautrix.AuthTypePassword,
|
||||
Identifier: mautrix.UserIdentifier{
|
||||
Type: mautrix.IdentifierTypeUser,
|
||||
User: cfg.UserID,
|
||||
},
|
||||
Password: cfg.Password,
|
||||
InitialDeviceDisplayName: cfg.DisplayName,
|
||||
}
|
||||
|
||||
if err := ch.Init(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||
}
|
||||
|
||||
// Attach crypto helper to client
|
||||
client.Crypto = ch
|
||||
|
||||
// Bootstrap cross-signing: generate keys, sign own device, sign master key.
|
||||
// This makes the bot's device show as "verified" to other users.
|
||||
mach := ch.Machine()
|
||||
recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": mautrix.AuthTypePassword,
|
||||
"identifier": map[string]interface{}{
|
||||
"type": mautrix.IdentifierTypeUser,
|
||||
"user": cfg.UserID,
|
||||
},
|
||||
"password": cfg.Password,
|
||||
"session": ui.Session,
|
||||
}
|
||||
}, "")
|
||||
if err != nil {
|
||||
slog.Warn("cross-signing: key upload failed (may already exist)", "err", err)
|
||||
} else {
|
||||
slog.Info("cross-signing: keys uploaded", "recovery_key", recoveryKey)
|
||||
}
|
||||
|
||||
if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil {
|
||||
slog.Warn("cross-signing: sign own device failed", "err", err)
|
||||
} else {
|
||||
slog.Info("cross-signing: own device signed")
|
||||
}
|
||||
|
||||
if err := mach.SignOwnMasterKey(context.Background()); err != nil {
|
||||
slog.Warn("cross-signing: sign master key failed", "err", err)
|
||||
} else {
|
||||
slog.Info("cross-signing: master key signed")
|
||||
}
|
||||
|
||||
slog.Info("E2EE initialized",
|
||||
"device_id", client.DeviceID,
|
||||
"crypto_store", "sqlite-persistent",
|
||||
)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func loadDevice(path string) (*DeviceInfo, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var info DeviceInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func saveDevice(path string, info *DeviceInfo) error {
|
||||
data, err := json.MarshalIndent(info, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
90
internal/bot/dispatch.go
Normal file
90
internal/bot/dispatch.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
)
|
||||
|
||||
// Registry manages plugin registration and event dispatch.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
plugins []plugin.Plugin
|
||||
}
|
||||
|
||||
// NewRegistry creates an empty plugin registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{}
|
||||
}
|
||||
|
||||
// Register adds a plugin to the registry.
|
||||
func (r *Registry) Register(p plugin.Plugin) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins = append(r.plugins, p)
|
||||
slog.Info("registered plugin", "name", p.Name())
|
||||
}
|
||||
|
||||
// Init initializes all registered plugins.
|
||||
func (r *Registry) Init() error {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, p := range r.plugins {
|
||||
if err := p.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DispatchMessage sends a message context to all plugins in order.
|
||||
func (r *Registry) DispatchMessage(ctx plugin.MessageContext) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, p := range r.plugins {
|
||||
if err := p.OnMessage(ctx); err != nil {
|
||||
slog.Error("plugin message handler error",
|
||||
"plugin", p.Name(),
|
||||
"err", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchReaction sends a reaction context to all plugins in order.
|
||||
func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, p := range r.plugins {
|
||||
if err := p.OnReaction(ctx); err != nil {
|
||||
slog.Error("plugin reaction handler error",
|
||||
"plugin", p.Name(),
|
||||
"err", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetCommands returns all command definitions from all plugins.
|
||||
func (r *Registry) GetCommands() []plugin.CommandDef {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var cmds []plugin.CommandDef
|
||||
for _, p := range r.plugins {
|
||||
cmds = append(cmds, p.Commands()...)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
// GetPlugin returns a plugin by name.
|
||||
func (r *Registry) GetPlugin(name string) plugin.Plugin {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, p := range r.plugins {
|
||||
if p.Name() == name {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
541
internal/db/db.go
Normal file
541
internal/db/db.go
Normal file
@@ -0,0 +1,541 @@
|
||||
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
|
||||
}
|
||||
476
internal/plugin/achievements.go
Normal file
476
internal/plugin/achievements.go
Normal file
@@ -0,0 +1,476 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// AchievementRegistry provides cross-plugin query access for achievements.
|
||||
type AchievementRegistry interface {
|
||||
GetCommands() []CommandDef
|
||||
}
|
||||
|
||||
// achievementDef describes a single achievement with its check function.
|
||||
type achievementDef struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Emoji string
|
||||
Check func(d *sql.DB, userID id.UserID) bool
|
||||
}
|
||||
|
||||
// AchievementsPlugin checks and grants achievements silently on every message.
|
||||
type AchievementsPlugin struct {
|
||||
Base
|
||||
registry AchievementRegistry
|
||||
achievements []achievementDef
|
||||
}
|
||||
|
||||
// NewAchievementsPlugin creates a new achievements plugin.
|
||||
func NewAchievementsPlugin(client *mautrix.Client, registry AchievementRegistry) *AchievementsPlugin {
|
||||
p := &AchievementsPlugin{
|
||||
Base: NewBase(client),
|
||||
registry: registry,
|
||||
}
|
||||
p.achievements = p.buildAchievements()
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) Name() string { return "achievements" }
|
||||
|
||||
func (p *AchievementsPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "achievements", Description: "List unlocked achievements", Usage: "!achievements [@user]", Category: "Leveling & Stats"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) Init() error { return nil }
|
||||
|
||||
func (p *AchievementsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *AchievementsPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "achievements") {
|
||||
return p.handleAchievements(ctx)
|
||||
}
|
||||
|
||||
// Passive: check achievements for the sender
|
||||
p.checkAndGrant(ctx.Sender)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAndGrant evaluates all achievement definitions and grants any newly unlocked ones.
|
||||
func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
|
||||
d := db.Get()
|
||||
|
||||
// Batch-fetch already unlocked achievements to avoid redundant checks
|
||||
unlocked := make(map[string]bool)
|
||||
rows, err := d.Query(`SELECT achievement_id FROM achievements WHERE user_id = ?`, string(userID))
|
||||
if err != nil {
|
||||
slog.Error("achievements: query unlocked", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var aid string
|
||||
if err := rows.Scan(&aid); err != nil {
|
||||
continue
|
||||
}
|
||||
unlocked[aid] = true
|
||||
}
|
||||
|
||||
// Only check achievements not yet unlocked
|
||||
for _, ach := range p.achievements {
|
||||
if unlocked[ach.ID] {
|
||||
continue
|
||||
}
|
||||
if ach.Check(d, userID) {
|
||||
p.grant(d, userID, ach.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
||||
string(userID), achievementID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "achievements")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT achievement_id, unlocked_at FROM achievements WHERE user_id = ? ORDER BY unlocked_at ASC`,
|
||||
string(target),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("achievements: list query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load achievements.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build a lookup map from achievement defs
|
||||
defMap := make(map[string]achievementDef)
|
||||
for _, a := range p.achievements {
|
||||
defMap[a.ID] = a
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Achievements for %s:\n\n", string(target)))
|
||||
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var achID string
|
||||
var unlockedAt int64
|
||||
if err := rows.Scan(&achID, &unlockedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
def, ok := defMap[achID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t := time.Unix(unlockedAt, 0).UTC().Format("2006-01-02")
|
||||
sb.WriteString(fmt.Sprintf("%s %s — %s (unlocked %s)\n", def.Emoji, def.Name, def.Description, t))
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s hasn't unlocked any achievements yet.", string(target)))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\n%d / %d achievements unlocked", count, len(p.achievements)))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// buildAchievements returns all 32 achievement definitions.
|
||||
func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
return []achievementDef{
|
||||
// Message milestones
|
||||
{
|
||||
ID: "first_message", Name: "First Steps", Description: "Sent your first message",
|
||||
Emoji: "👶",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 1) },
|
||||
},
|
||||
{
|
||||
ID: "100_messages", Name: "Chatterbox", Description: "Sent 100 messages",
|
||||
Emoji: "💬",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 100) },
|
||||
},
|
||||
{
|
||||
ID: "1000_messages", Name: "Motor Mouth", Description: "Sent 1,000 messages",
|
||||
Emoji: "🗣️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 1000) },
|
||||
},
|
||||
{
|
||||
ID: "10000_messages", Name: "Legend", Description: "Sent 10,000 messages",
|
||||
Emoji: "🏛️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 10000) },
|
||||
},
|
||||
|
||||
// Time-based
|
||||
{
|
||||
ID: "night_owl", Name: "Night Owl", Description: "Sent 100 messages between midnight and 6am",
|
||||
Emoji: "🦉",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "night_messages", 100) },
|
||||
},
|
||||
{
|
||||
ID: "early_bird", Name: "Early Bird", Description: "Sent 100 messages between 5am and 9am",
|
||||
Emoji: "🐦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "morning_messages", 100) },
|
||||
},
|
||||
|
||||
// Content
|
||||
{
|
||||
ID: "wordsmith", Name: "Wordsmith", Description: "Average message length over 8 words",
|
||||
Emoji: "✍️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var totalMessages, totalWords int
|
||||
err := d.QueryRow(
|
||||
`SELECT total_messages, total_words FROM user_stats WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&totalMessages, &totalWords)
|
||||
if err != nil || totalMessages < 50 {
|
||||
return false
|
||||
}
|
||||
return float64(totalWords)/float64(totalMessages) > 8.0
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "link_collector", Name: "Link Collector", Description: "Shared 50 links",
|
||||
Emoji: "🔗",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_links", 50) },
|
||||
},
|
||||
{
|
||||
ID: "shutterbug", Name: "Shutterbug", Description: "Shared 20 images",
|
||||
Emoji: "📸",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_images", 20) },
|
||||
},
|
||||
{
|
||||
ID: "question_master", Name: "Question Master", Description: "Asked 100 questions",
|
||||
Emoji: "❓",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_questions", 100) },
|
||||
},
|
||||
|
||||
// Social
|
||||
{
|
||||
ID: "welcome_wagon", Name: "Welcome Wagon", Description: "Welcomed a new member to the community",
|
||||
Emoji: "👋",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// This is checked externally when a new user joins
|
||||
// Here we just check if it was already granted
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "rep_magnet", Name: "Rep Magnet", Description: "Received 10 reputation points",
|
||||
Emoji: "💜",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var total int
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
|
||||
string(u),
|
||||
).Scan(&total)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return total/5 >= 10
|
||||
},
|
||||
},
|
||||
|
||||
// Streaks
|
||||
{
|
||||
ID: "week_streak", Name: "Weekly Warrior", Description: "Active for 7 consecutive days",
|
||||
Emoji: "📅",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 7) },
|
||||
},
|
||||
{
|
||||
ID: "month_streak", Name: "Monthly Marvel", Description: "Active for 30 consecutive days",
|
||||
Emoji: "🗓️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 30) },
|
||||
},
|
||||
|
||||
// Trivia
|
||||
{
|
||||
ID: "trivia_novice", Name: "Trivia Novice", Description: "Answered 10 trivia questions correctly",
|
||||
Emoji: "🧠",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var correct int
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(SUM(correct), 0) FROM trivia_scores WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&correct)
|
||||
return err == nil && correct >= 10
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "trivia_master", Name: "Trivia Master", Description: "Answered 100 trivia questions correctly",
|
||||
Emoji: "🎓",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var correct int
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(SUM(correct), 0) FROM trivia_scores WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&correct)
|
||||
return err == nil && correct >= 100
|
||||
},
|
||||
},
|
||||
|
||||
// Special
|
||||
{
|
||||
ID: "markov_victim", Name: "Markov Victim", Description: "Had your words remixed by the Markov chain",
|
||||
Emoji: "🤖",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted externally by the markov plugin
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "logophile", Name: "Logophile", Description: "Used 10 different Words of the Day",
|
||||
Emoji: "📖",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
|
||||
string(u),
|
||||
).Scan(&count)
|
||||
return err == nil && count >= 10
|
||||
},
|
||||
},
|
||||
|
||||
// Additional milestones to reach 32 total
|
||||
{
|
||||
ID: "emoji_lover", Name: "Emoji Lover", Description: "Used 500 emojis in messages",
|
||||
Emoji: "😍",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_emojis", 500) },
|
||||
},
|
||||
{
|
||||
ID: "exclaimer", Name: "Exclaimer", Description: "Used 200 exclamation marks",
|
||||
Emoji: "❗",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_exclamations", 200) },
|
||||
},
|
||||
{
|
||||
ID: "two_week_streak", Name: "Fortnight Fighter", Description: "Active for 14 consecutive days",
|
||||
Emoji: "⚔️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 14) },
|
||||
},
|
||||
{
|
||||
ID: "quarter_streak", Name: "Seasonal Sage", Description: "Active for 90 consecutive days",
|
||||
Emoji: "🌿",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 90) },
|
||||
},
|
||||
{
|
||||
ID: "500_messages", Name: "Conversationalist", Description: "Sent 500 messages",
|
||||
Emoji: "🎙️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 500) },
|
||||
},
|
||||
{
|
||||
ID: "5000_messages", Name: "Veteran", Description: "Sent 5,000 messages",
|
||||
Emoji: "🎖️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 5000) },
|
||||
},
|
||||
{
|
||||
ID: "link_hoarder", Name: "Link Hoarder", Description: "Shared 200 links",
|
||||
Emoji: "🌐",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_links", 200) },
|
||||
},
|
||||
{
|
||||
ID: "photographer", Name: "Photographer", Description: "Shared 100 images",
|
||||
Emoji: "🖼️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_images", 100) },
|
||||
},
|
||||
{
|
||||
ID: "trivia_legend", Name: "Trivia Legend", Description: "Answered 500 trivia questions correctly",
|
||||
Emoji: "👑",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var correct int
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(SUM(correct), 0) FROM trivia_scores WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&correct)
|
||||
return err == nil && correct >= 500
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "rep_star", Name: "Reputation Star", Description: "Received 50 reputation points",
|
||||
Emoji: "⭐",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var total int
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
|
||||
string(u),
|
||||
).Scan(&total)
|
||||
return err == nil && total/5 >= 50
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "night_dweller", Name: "Night Dweller", Description: "Sent 500 night messages",
|
||||
Emoji: "🌙",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "night_messages", 500) },
|
||||
},
|
||||
{
|
||||
ID: "dawn_patrol", Name: "Dawn Patrol", Description: "Sent 500 morning messages",
|
||||
Emoji: "🌅",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "morning_messages", 500) },
|
||||
},
|
||||
{
|
||||
ID: "wotd_apprentice", Name: "WOTD Apprentice", Description: "Used 5 different Words of the Day",
|
||||
Emoji: "📝",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
|
||||
string(u),
|
||||
).Scan(&count)
|
||||
return err == nil && count >= 5
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "year_streak", Name: "Year-Long Devotion", Description: "Active for 365 consecutive days",
|
||||
Emoji: "🏆",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 365) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// statGTE checks if a user_stats column is >= threshold.
|
||||
func statGTE(d *sql.DB, userID id.UserID, column string, threshold int) bool {
|
||||
var val int
|
||||
query := fmt.Sprintf(`SELECT COALESCE(%s, 0) FROM user_stats WHERE user_id = ?`, column)
|
||||
err := d.QueryRow(query, string(userID)).Scan(&val)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return val >= threshold
|
||||
}
|
||||
|
||||
// checkStreak checks if a user has been active for N consecutive days ending today (or recently).
|
||||
func checkStreak(d *sql.DB, userID id.UserID, days int) bool {
|
||||
rows, err := d.Query(
|
||||
`SELECT date FROM daily_activity WHERE user_id = ? ORDER BY date DESC LIMIT ?`,
|
||||
string(userID), days+7, // fetch a few extra to find the streak
|
||||
)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
dates := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var date string
|
||||
if err := rows.Scan(&date); err != nil {
|
||||
continue
|
||||
}
|
||||
dates[date] = true
|
||||
}
|
||||
|
||||
if len(dates) < days {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for a consecutive run of `days` days ending at any recent date
|
||||
today := time.Now().UTC()
|
||||
for offset := 0; offset < 7; offset++ {
|
||||
streak := 0
|
||||
for i := 0; i < days; i++ {
|
||||
dateStr := today.AddDate(0, 0, -(offset + i)).Format("2006-01-02")
|
||||
if dates[dateStr] {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if streak >= days {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GrantAchievement allows external plugins to grant specific achievements.
|
||||
func (p *AchievementsPlugin) GrantAchievement(userID id.UserID, achievementID string) {
|
||||
d := db.Get()
|
||||
p.grant(d, userID, achievementID)
|
||||
}
|
||||
550
internal/plugin/anime.go
Normal file
550
internal/plugin/anime.go
Normal file
@@ -0,0 +1,550 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// jikanAnime represents an anime entry from the Jikan API.
|
||||
type jikanAnime struct {
|
||||
MalID int `json:"mal_id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
TitleEng string `json:"title_english"`
|
||||
Type string `json:"type"`
|
||||
Episodes int `json:"episodes"`
|
||||
Status string `json:"status"`
|
||||
Score float64 `json:"score"`
|
||||
Synopsis string `json:"synopsis"`
|
||||
Aired struct {
|
||||
String string `json:"string"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
} `json:"aired"`
|
||||
Broadcast struct {
|
||||
Day string `json:"day"`
|
||||
Time string `json:"time"`
|
||||
TZ string `json:"timezone"`
|
||||
} `json:"broadcast"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
Studios []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"studios"`
|
||||
Season string `json:"season"`
|
||||
Year int `json:"year"`
|
||||
}
|
||||
|
||||
// jikanSearchResponse is the Jikan search API response.
|
||||
type jikanSearchResponse struct {
|
||||
Data []jikanAnime `json:"data"`
|
||||
Pagination struct {
|
||||
HasNext int `json:"has_next_page"`
|
||||
} `json:"pagination"`
|
||||
}
|
||||
|
||||
// jikanSeasonResponse is the Jikan season API response.
|
||||
type jikanSeasonResponse struct {
|
||||
Data []jikanAnime `json:"data"`
|
||||
}
|
||||
|
||||
// AnimePlugin provides anime lookups via the Jikan/MAL API.
|
||||
type AnimePlugin struct {
|
||||
Base
|
||||
httpClient *http.Client
|
||||
mu sync.Mutex
|
||||
lastCall time.Time // for rate limiting Jikan (400ms between calls)
|
||||
}
|
||||
|
||||
// NewAnimePlugin creates a new AnimePlugin.
|
||||
func NewAnimePlugin(client *mautrix.Client) *AnimePlugin {
|
||||
return &AnimePlugin{
|
||||
Base: NewBase(client),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) Name() string { return "anime" }
|
||||
|
||||
func (p *AnimePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "anime", Description: "Search for anime info", Usage: "!anime <title>", Category: "Entertainment"},
|
||||
{Name: "anime watch", Description: "Add anime to your watchlist", Usage: "!anime watch <title>", Category: "Entertainment"},
|
||||
{Name: "anime watching", Description: "List your anime watchlist", Usage: "!anime watching", Category: "Entertainment"},
|
||||
{Name: "anime unwatch", Description: "Remove anime from watchlist by MAL ID", Usage: "!anime unwatch <id>", Category: "Entertainment"},
|
||||
{Name: "anime season", Description: "Show current season anime", Usage: "!anime season", Category: "Entertainment"},
|
||||
{Name: "anime upcoming", Description: "Show upcoming anime", Usage: "!anime upcoming", Category: "Entertainment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) Init() error { return nil }
|
||||
|
||||
func (p *AnimePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *AnimePlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.IsCommand(ctx.Body, "anime") {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := p.GetArgs(ctx.Body, "anime")
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Usage: !anime <title> | !anime watch|watching|unwatch|season|upcoming")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
sub := strings.ToLower(parts[0])
|
||||
|
||||
switch sub {
|
||||
case "watch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime watch <title>")
|
||||
}
|
||||
return p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
||||
case "watching":
|
||||
return p.handleWatching(ctx)
|
||||
case "unwatch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime unwatch <id>")
|
||||
}
|
||||
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
||||
case "season":
|
||||
return p.handleSeason(ctx)
|
||||
case "upcoming":
|
||||
return p.handleUpcoming(ctx)
|
||||
default:
|
||||
return p.handleSearch(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// rateLimit enforces the 400ms delay between Jikan API calls.
|
||||
func (p *AnimePlugin) rateLimit() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
elapsed := time.Since(p.lastCall)
|
||||
if elapsed < 400*time.Millisecond {
|
||||
time.Sleep(400*time.Millisecond - elapsed)
|
||||
}
|
||||
p.lastCall = time.Now()
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) jikanGet(apiURL string, target interface{}) error {
|
||||
p.rateLimit()
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("jikan returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) handleSearch(ctx MessageContext, query string) error {
|
||||
d := db.Get()
|
||||
|
||||
// Search via API
|
||||
encoded := url.QueryEscape(query)
|
||||
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?q=%s&limit=3&sfw=true", encoded)
|
||||
|
||||
var searchResp jikanSearchResponse
|
||||
if err := p.jikanGet(apiURL, &searchResp); err != nil {
|
||||
slog.Error("anime: search failed", "query", query, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for anime.")
|
||||
}
|
||||
|
||||
if len(searchResp.Data) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No anime found for \"%s\".", query))
|
||||
}
|
||||
|
||||
anime := searchResp.Data[0]
|
||||
|
||||
// Cache the result
|
||||
data, _ := json.Marshal(anime)
|
||||
if _, err := d.Exec(
|
||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
); err != nil {
|
||||
slog.Error("anime: cache write", "err", err)
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatAnime(anime))
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) formatAnime(a jikanAnime) string {
|
||||
var sb strings.Builder
|
||||
|
||||
title := a.Title
|
||||
if a.TitleEng != "" && a.TitleEng != a.Title {
|
||||
title = fmt.Sprintf("%s (%s)", a.TitleEng, a.Title)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s\n", title))
|
||||
sb.WriteString(fmt.Sprintf("MAL ID: %d\n", a.MalID))
|
||||
|
||||
if a.Type != "" {
|
||||
sb.WriteString(fmt.Sprintf("Type: %s", a.Type))
|
||||
if a.Episodes > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (%d episodes)", a.Episodes))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if a.Status != "" {
|
||||
sb.WriteString(fmt.Sprintf("Status: %s\n", a.Status))
|
||||
}
|
||||
|
||||
if a.Score > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Score: %.2f/10\n", a.Score))
|
||||
}
|
||||
|
||||
if a.Aired.String != "" {
|
||||
sb.WriteString(fmt.Sprintf("Aired: %s\n", a.Aired.String))
|
||||
}
|
||||
|
||||
if len(a.Genres) > 0 {
|
||||
genres := make([]string, len(a.Genres))
|
||||
for i, g := range a.Genres {
|
||||
genres[i] = g.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||
}
|
||||
|
||||
if len(a.Studios) > 0 {
|
||||
studios := make([]string, len(a.Studios))
|
||||
for i, s := range a.Studios {
|
||||
studios[i] = s.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Studios: %s\n", strings.Join(studios, ", ")))
|
||||
}
|
||||
|
||||
if a.Broadcast.Day != "" {
|
||||
sb.WriteString(fmt.Sprintf("Broadcast: %s %s (%s)\n", a.Broadcast.Day, a.Broadcast.Time, a.Broadcast.TZ))
|
||||
}
|
||||
|
||||
if a.Synopsis != "" {
|
||||
synopsis := a.Synopsis
|
||||
if len(synopsis) > 300 {
|
||||
synopsis = synopsis[:300] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n%s\n", synopsis))
|
||||
}
|
||||
|
||||
if a.URL != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n%s", a.URL))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) handleWatch(ctx MessageContext, title string) error {
|
||||
// Search for the anime first to get its MAL ID
|
||||
encoded := url.QueryEscape(title)
|
||||
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?q=%s&limit=1&sfw=true", encoded)
|
||||
|
||||
var searchResp jikanSearchResponse
|
||||
if err := p.jikanGet(apiURL, &searchResp); err != nil {
|
||||
slog.Error("anime: watch search failed", "title", title, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for anime.")
|
||||
}
|
||||
|
||||
if len(searchResp.Data) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No anime found for \"%s\".", title))
|
||||
}
|
||||
|
||||
anime := searchResp.Data[0]
|
||||
d := db.Get()
|
||||
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO anime_watchlist (user_id, mal_id, title, room_id) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, mal_id) DO NOTHING`,
|
||||
string(ctx.Sender), anime.MalID, anime.Title, string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("anime: watchlist add", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
||||
}
|
||||
|
||||
// Also cache the anime data
|
||||
data, _ := json.Marshal(anime)
|
||||
_, _ = d.Exec(
|
||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
|
||||
displayTitle := anime.Title
|
||||
if anime.TitleEng != "" {
|
||||
displayTitle = anime.TitleEng
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Added \"%s\" (MAL ID: %d) to your watchlist.", displayTitle, anime.MalID))
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) handleWatching(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT mal_id, title FROM anime_watchlist WHERE user_id = ? ORDER BY title`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("anime: watching list", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Your Anime Watchlist:\n\n")
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var malID int
|
||||
var title string
|
||||
if err := rows.Scan(&malID, &title); err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s\n", malID, title))
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Your anime watchlist is empty. Use !anime watch <title> to add anime.")
|
||||
}
|
||||
|
||||
sb.WriteString("\nUse !anime unwatch <id> to remove an entry.")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) handleUnwatch(ctx MessageContext, idStr string) error {
|
||||
malID, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Please provide a valid MAL ID number. Check !anime watching for your list.")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
res, err := d.Exec(
|
||||
`DELETE FROM anime_watchlist WHERE user_id = ? AND mal_id = ?`,
|
||||
string(ctx.Sender), malID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("anime: unwatch", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("MAL ID %d is not in your watchlist.", malID))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed MAL ID %d from your watchlist.", malID))
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) handleSeason(ctx MessageContext) error {
|
||||
apiURL := "https://api.jikan.moe/v4/seasons/now?limit=10&sfw=true"
|
||||
|
||||
var resp jikanSeasonResponse
|
||||
if err := p.jikanGet(apiURL, &resp); err != nil {
|
||||
slog.Error("anime: season fetch failed", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch current season anime.")
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No anime found for the current season.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Current Season Anime:\n\n")
|
||||
|
||||
for i, a := range resp.Data {
|
||||
title := a.Title
|
||||
if a.TitleEng != "" {
|
||||
title = a.TitleEng
|
||||
}
|
||||
scoreStr := "N/A"
|
||||
if a.Score > 0 {
|
||||
scoreStr = fmt.Sprintf("%.1f", a.Score)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s [%s] - Score: %s\n", i+1, title, a.Type, scoreStr))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) handleUpcoming(ctx MessageContext) error {
|
||||
apiURL := "https://api.jikan.moe/v4/seasons/upcoming?limit=10&sfw=true"
|
||||
|
||||
var resp jikanSeasonResponse
|
||||
if err := p.jikanGet(apiURL, &resp); err != nil {
|
||||
slog.Error("anime: upcoming fetch failed", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch upcoming anime.")
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No upcoming anime found.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Upcoming Anime:\n\n")
|
||||
|
||||
for i, a := range resp.Data {
|
||||
title := a.Title
|
||||
if a.TitleEng != "" {
|
||||
title = a.TitleEng
|
||||
}
|
||||
info := a.Type
|
||||
if a.Aired.String != "" {
|
||||
info += " | " + a.Aired.String
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s [%s]\n", i+1, title, info))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// PostDailyReleases checks broadcast days and DMs watchers about anime airing today.
|
||||
// Intended to be called by the scheduler daily.
|
||||
func (p *AnimePlugin) PostDailyReleases(roomID id.RoomID) {
|
||||
todayKey := time.Now().UTC().Format("2006-01-02")
|
||||
if db.JobCompleted("anime_releases", todayKey) {
|
||||
slog.Info("anime: already sent daily releases", "date", todayKey)
|
||||
return
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if success {
|
||||
db.MarkJobCompleted("anime_releases", todayKey)
|
||||
}
|
||||
}()
|
||||
|
||||
d := db.Get()
|
||||
today := strings.ToLower(time.Now().Weekday().String())
|
||||
// Jikan uses plural day names like "Mondays", "Tuesdays", etc.
|
||||
todayPlural := today + "s"
|
||||
|
||||
// Get all unique MAL IDs from watchlists
|
||||
rows, err := d.Query(`SELECT DISTINCT mal_id, title FROM anime_watchlist`)
|
||||
if err != nil {
|
||||
slog.Error("anime: daily releases query", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
type animeEntry struct {
|
||||
malID int
|
||||
title string
|
||||
}
|
||||
var watchedAnime []animeEntry
|
||||
for rows.Next() {
|
||||
var e animeEntry
|
||||
if err := rows.Scan(&e.malID, &e.title); err != nil {
|
||||
continue
|
||||
}
|
||||
watchedAnime = append(watchedAnime, e)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, entry := range watchedAnime {
|
||||
// Check cache or fetch anime details
|
||||
var animeData jikanAnime
|
||||
var cached string
|
||||
var cachedAt int64
|
||||
|
||||
err := d.QueryRow(
|
||||
`SELECT data, cached_at FROM anime_cache WHERE mal_id = ?`, entry.malID,
|
||||
).Scan(&cached, &cachedAt)
|
||||
|
||||
needsFetch := err != nil || time.Now().Unix()-cachedAt > 24*3600
|
||||
|
||||
if !needsFetch {
|
||||
if json.Unmarshal([]byte(cached), &animeData) != nil {
|
||||
needsFetch = true
|
||||
}
|
||||
}
|
||||
|
||||
if needsFetch {
|
||||
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", entry.malID)
|
||||
var resp struct {
|
||||
Data jikanAnime `json:"data"`
|
||||
}
|
||||
if err := p.jikanGet(apiURL, &resp); err != nil {
|
||||
slog.Error("anime: daily fetch", "mal_id", entry.malID, "err", err)
|
||||
continue
|
||||
}
|
||||
animeData = resp.Data
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(animeData)
|
||||
_, _ = d.Exec(
|
||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
entry.malID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this anime broadcasts today
|
||||
broadcastDay := strings.ToLower(animeData.Broadcast.Day)
|
||||
if broadcastDay != todayPlural && broadcastDay != today {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only notify for currently airing anime
|
||||
if animeData.Status != "Currently Airing" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find watchers for this anime
|
||||
wRows, err := d.Query(
|
||||
`SELECT user_id FROM anime_watchlist WHERE mal_id = ?`, entry.malID,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
displayTitle := animeData.Title
|
||||
if animeData.TitleEng != "" {
|
||||
displayTitle = animeData.TitleEng
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("New episode alert! %s airs today (%s %s %s).\n%s",
|
||||
displayTitle, animeData.Broadcast.Day, animeData.Broadcast.Time,
|
||||
animeData.Broadcast.TZ, animeData.URL)
|
||||
|
||||
for wRows.Next() {
|
||||
var uid string
|
||||
if err := wRows.Scan(&uid); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := p.SendDM(id.UserID(uid), msg); err != nil {
|
||||
slog.Error("anime: DM watcher", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
wRows.Close()
|
||||
}
|
||||
|
||||
success = true
|
||||
slog.Info("anime: daily releases check completed")
|
||||
}
|
||||
386
internal/plugin/birthday.go
Normal file
386
internal/plugin/birthday.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// BirthdayPlugin manages user birthdays with reminders and announcements.
|
||||
type BirthdayPlugin struct {
|
||||
Base
|
||||
xp *XPPlugin
|
||||
}
|
||||
|
||||
// NewBirthdayPlugin creates a new BirthdayPlugin.
|
||||
func NewBirthdayPlugin(client *mautrix.Client, xp *XPPlugin) *BirthdayPlugin {
|
||||
return &BirthdayPlugin{
|
||||
Base: NewBase(client),
|
||||
xp: xp,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BirthdayPlugin) Name() string { return "birthday" }
|
||||
|
||||
func (p *BirthdayPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "birthday", Description: "Set, show, or remove your birthday", Usage: "!birthday set <MM-DD> | !birthday set <MM-DD-YYYY> | !birthday show | !birthday remove", Category: "Personal"},
|
||||
{Name: "birthdays", Description: "Show upcoming birthdays (next 30 days)", Usage: "!birthdays", Category: "Personal"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BirthdayPlugin) Init() error { return nil }
|
||||
|
||||
func (p *BirthdayPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *BirthdayPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "birthdays") {
|
||||
return p.handleUpcoming(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "birthday") {
|
||||
return p.handleBirthday(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *BirthdayPlugin) handleBirthday(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "birthday"))
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !birthday set <MM-DD> | !birthday show | !birthday remove")
|
||||
}
|
||||
|
||||
sub := strings.ToLower(parts[0])
|
||||
switch sub {
|
||||
case "set":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !birthday set <MM-DD> or !birthday set <MM-DD-YYYY>")
|
||||
}
|
||||
return p.handleSet(ctx, strings.TrimSpace(parts[1]))
|
||||
case "show":
|
||||
return p.handleShow(ctx)
|
||||
case "remove":
|
||||
return p.handleRemove(ctx)
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !birthday set <MM-DD> | !birthday show | !birthday remove")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BirthdayPlugin) handleSet(ctx MessageContext, dateStr string) error {
|
||||
month, day, year, err := parseBirthdayDate(dateStr)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid date format: %s. Use MM-DD or MM-DD-YYYY.", err.Error()))
|
||||
}
|
||||
|
||||
// Validate the date makes sense
|
||||
if month < 1 || month > 12 || day < 1 || day > 31 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid date. Month must be 1-12 and day must be 1-31.")
|
||||
}
|
||||
|
||||
// Validate day for the given month
|
||||
if year > 0 {
|
||||
t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
if t.Month() != time.Month(month) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid date: %s %d doesn't have %d days.", time.Month(month), year, day))
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO birthdays (user_id, month, day, year, timezone) VALUES (?, ?, ?, ?, 'UTC')
|
||||
ON CONFLICT(user_id) DO UPDATE SET month = ?, day = ?, year = ?`,
|
||||
string(ctx.Sender), month, day, year, month, day, year,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("birthday: set", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save your birthday.")
|
||||
}
|
||||
|
||||
display := fmt.Sprintf("%s %d", time.Month(month), day)
|
||||
if year > 0 {
|
||||
display += fmt.Sprintf(", %d", year)
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Birthday set to %s!", display))
|
||||
}
|
||||
|
||||
func (p *BirthdayPlugin) handleShow(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
var month, day, year int
|
||||
err := d.QueryRow(
|
||||
`SELECT month, day, year FROM birthdays WHERE user_id = ?`, string(ctx.Sender),
|
||||
).Scan(&month, &day, &year)
|
||||
if err == sql.ErrNoRows {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You haven't set your birthday yet. Use !birthday set <MM-DD>.")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("birthday: show", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch your birthday.")
|
||||
}
|
||||
|
||||
display := fmt.Sprintf("%s %d", time.Month(month), day)
|
||||
if year > 0 {
|
||||
display += fmt.Sprintf(", %d", year)
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Your birthday: %s", display))
|
||||
}
|
||||
|
||||
func (p *BirthdayPlugin) handleRemove(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`DELETE FROM birthdays WHERE user_id = ?`, string(ctx.Sender))
|
||||
if err != nil {
|
||||
slog.Error("birthday: remove", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove your birthday.")
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You don't have a birthday set.")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Birthday removed.")
|
||||
}
|
||||
|
||||
func (p *BirthdayPlugin) handleUpcoming(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
now := time.Now().UTC()
|
||||
|
||||
rows, err := d.Query(`SELECT user_id, month, day, year FROM birthdays ORDER BY month, day`)
|
||||
if err != nil {
|
||||
slog.Error("birthday: upcoming query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch upcoming birthdays.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type bdayEntry struct {
|
||||
UserID string
|
||||
Month int
|
||||
Day int
|
||||
Year int
|
||||
DaysTo int
|
||||
}
|
||||
|
||||
var upcoming []bdayEntry
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var month, day, year int
|
||||
if err := rows.Scan(&userID, &month, &day, &year); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
daysTo := daysUntilBirthday(now, month, day)
|
||||
if daysTo <= 30 {
|
||||
upcoming = append(upcoming, bdayEntry{
|
||||
UserID: userID,
|
||||
Month: month,
|
||||
Day: day,
|
||||
Year: year,
|
||||
DaysTo: daysTo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(upcoming) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No upcoming birthdays in the next 30 days.")
|
||||
}
|
||||
|
||||
// Sort by days until birthday
|
||||
for i := 0; i < len(upcoming); i++ {
|
||||
for j := i + 1; j < len(upcoming); j++ {
|
||||
if upcoming[j].DaysTo < upcoming[i].DaysTo {
|
||||
upcoming[i], upcoming[j] = upcoming[j], upcoming[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Upcoming Birthdays (next 30 days):\n")
|
||||
for _, b := range upcoming {
|
||||
display := fmt.Sprintf("%s %d", time.Month(b.Month), b.Day)
|
||||
if b.Year > 0 {
|
||||
age := now.Year() - b.Year
|
||||
// Adjust if birthday hasn't happened yet this year
|
||||
bdayThisYear := time.Date(now.Year(), time.Month(b.Month), b.Day, 0, 0, 0, 0, time.UTC)
|
||||
if now.Before(bdayThisYear) {
|
||||
age--
|
||||
}
|
||||
display += fmt.Sprintf(" (turning %d)", age+1)
|
||||
}
|
||||
|
||||
when := "TODAY!"
|
||||
if b.DaysTo == 1 {
|
||||
when = "tomorrow"
|
||||
} else if b.DaysTo > 1 {
|
||||
when = fmt.Sprintf("in %d days", b.DaysTo)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s — %s\n", b.UserID, display, when))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// CheckAndPost checks for birthdays today and posts announcements.
|
||||
func (p *BirthdayPlugin) CheckAndPost(roomID id.RoomID) error {
|
||||
now := time.Now().UTC()
|
||||
month := int(now.Month())
|
||||
day := now.Day()
|
||||
year := now.Year()
|
||||
|
||||
d := db.Get()
|
||||
|
||||
// Collect all birthday matches first to avoid nested queries on a single SQLite connection
|
||||
type bdayMatch struct {
|
||||
userID string
|
||||
birthYear int
|
||||
}
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, year FROM birthdays WHERE month = ? AND day = ?`, month, day,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("birthday: check query: %w", err)
|
||||
}
|
||||
var matches []bdayMatch
|
||||
for rows.Next() {
|
||||
var m bdayMatch
|
||||
if err := rows.Scan(&m.userID, &m.birthYear); err != nil {
|
||||
slog.Error("birthday: scan", "err", err)
|
||||
continue
|
||||
}
|
||||
matches = append(matches, m)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("birthday: rows iteration: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
// Check if already fired this year
|
||||
var fired int
|
||||
err := d.QueryRow(
|
||||
`SELECT 1 FROM birthday_fired WHERE user_id = ? AND year = ?`, m.userID, year,
|
||||
).Scan(&fired)
|
||||
if err == nil {
|
||||
continue // Already fired this year
|
||||
}
|
||||
|
||||
// Build announcement
|
||||
var ageStr string
|
||||
if m.birthYear > 0 {
|
||||
age := year - m.birthYear
|
||||
ageStr = fmt.Sprintf(" They're turning %d!", age)
|
||||
}
|
||||
|
||||
announcement := fmt.Sprintf("Happy Birthday to %s!%s Have an amazing day!", m.userID, ageStr)
|
||||
if err := p.SendMessage(roomID, announcement); err != nil {
|
||||
slog.Error("birthday: send announcement", "user", m.userID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send DM to the birthday person
|
||||
dmMsg := fmt.Sprintf("Happy Birthday! The community wishes you a wonderful day!%s You've been granted 100 bonus XP as a birthday gift!", ageStr)
|
||||
if err := p.SendDM(id.UserID(m.userID), dmMsg); err != nil {
|
||||
slog.Error("birthday: send DM", "user", m.userID, "err", err)
|
||||
}
|
||||
|
||||
// Grant 100 XP
|
||||
if p.xp != nil {
|
||||
p.xp.GrantXP(id.UserID(m.userID), 100, "birthday")
|
||||
} else {
|
||||
p.grantBirthdayXP(id.UserID(m.userID), 100)
|
||||
}
|
||||
|
||||
// Mark as fired
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO birthday_fired (user_id, year) VALUES (?, ?)
|
||||
ON CONFLICT(user_id, year) DO NOTHING`,
|
||||
m.userID, year,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("birthday: mark fired", "user", m.userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantBirthdayXP is a fallback that inserts XP directly via SQL if no XPPlugin is available.
|
||||
func (p *BirthdayPlugin) grantBirthdayXP(userID id.UserID, amount int) {
|
||||
d := db.Get()
|
||||
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO users (user_id, xp, level, last_xp_at) VALUES (?, 0, 0, 0)
|
||||
ON CONFLICT(user_id) DO NOTHING`, string(userID))
|
||||
if err != nil {
|
||||
slog.Error("birthday: ensure user", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = d.Exec(
|
||||
`UPDATE users SET xp = xp + ?, last_xp_at = ? WHERE user_id = ?`,
|
||||
amount, time.Now().UTC().Unix(), string(userID))
|
||||
if err != nil {
|
||||
slog.Error("birthday: grant xp", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||
string(userID), amount, "birthday")
|
||||
if err != nil {
|
||||
slog.Error("birthday: log xp", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseBirthdayDate parses MM-DD or MM-DD-YYYY format.
|
||||
func parseBirthdayDate(s string) (month, day, year int, err error) {
|
||||
parts := strings.Split(s, "-")
|
||||
switch len(parts) {
|
||||
case 2:
|
||||
// MM-DD
|
||||
month, err = strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("invalid month: %s", parts[0])
|
||||
}
|
||||
day, err = strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("invalid day: %s", parts[1])
|
||||
}
|
||||
return month, day, 0, nil
|
||||
case 3:
|
||||
// MM-DD-YYYY
|
||||
month, err = strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("invalid month: %s", parts[0])
|
||||
}
|
||||
day, err = strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("invalid day: %s", parts[1])
|
||||
}
|
||||
year, err = strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("invalid year: %s", parts[2])
|
||||
}
|
||||
return month, day, year, nil
|
||||
default:
|
||||
return 0, 0, 0, fmt.Errorf("use MM-DD or MM-DD-YYYY format")
|
||||
}
|
||||
}
|
||||
|
||||
// daysUntilBirthday calculates days from now until the next occurrence of the given month/day.
|
||||
func daysUntilBirthday(now time.Time, month, day int) int {
|
||||
thisYear := time.Date(now.Year(), time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if thisYear.Before(today) {
|
||||
// Birthday already passed this year, calculate for next year
|
||||
thisYear = time.Date(now.Year()+1, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
return int(thisYear.Sub(today).Hours() / 24)
|
||||
}
|
||||
154
internal/plugin/botinfo.go
Normal file
154
internal/plugin/botinfo.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
|
||||
var (
|
||||
botStartTime = time.Now().UTC()
|
||||
botMsgCounter atomic.Int64
|
||||
)
|
||||
|
||||
// IncrementMessageCount increments the global session message counter.
|
||||
func IncrementMessageCount() {
|
||||
botMsgCounter.Add(1)
|
||||
}
|
||||
|
||||
// BotInfoPlugin provides admin-only bot diagnostics.
|
||||
type BotInfoPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewBotInfoPlugin creates a new botinfo plugin.
|
||||
func NewBotInfoPlugin(client *mautrix.Client) *BotInfoPlugin {
|
||||
return &BotInfoPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BotInfoPlugin) Name() string { return "botinfo" }
|
||||
|
||||
func (p *BotInfoPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "botinfo", Description: "Show bot diagnostics (admin only)", Usage: "!botinfo", Category: "Admin", AdminOnly: true},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BotInfoPlugin) Init() error { return nil }
|
||||
|
||||
func (p *BotInfoPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *BotInfoPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.IsCommand(ctx.Body, "botinfo") {
|
||||
// Passively count messages
|
||||
IncrementMessageCount()
|
||||
return nil
|
||||
}
|
||||
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "This command is admin-only.")
|
||||
}
|
||||
|
||||
return p.handleBotInfo(ctx)
|
||||
}
|
||||
|
||||
func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Bot Diagnostics\n\n")
|
||||
|
||||
// Uptime
|
||||
uptime := time.Since(botStartTime)
|
||||
days := int(uptime.Hours() / 24)
|
||||
hours := int(uptime.Hours()) % 24
|
||||
minutes := int(uptime.Minutes()) % 60
|
||||
sb.WriteString(fmt.Sprintf("Uptime: %dd %dh %dm\n", days, hours, minutes))
|
||||
|
||||
// Session message count
|
||||
sessionMsgs := botMsgCounter.Load()
|
||||
sb.WriteString(fmt.Sprintf("Messages this session: %d\n", sessionMsgs))
|
||||
|
||||
// Total room messages from DB
|
||||
d := db.Get()
|
||||
var totalMsgs int
|
||||
err := d.QueryRow(`SELECT COALESCE(SUM(total_messages), 0) FROM user_stats`).Scan(&totalMsgs)
|
||||
if err != nil {
|
||||
slog.Error("botinfo: total messages", "err", err)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Total room messages (DB): %s\n", formatNumber(totalMsgs)))
|
||||
|
||||
// DB size
|
||||
var pageCount, pageSize int
|
||||
_ = d.QueryRow(`PRAGMA page_count`).Scan(&pageCount)
|
||||
_ = d.QueryRow(`PRAGMA page_size`).Scan(&pageSize)
|
||||
dbSizeBytes := int64(pageCount) * int64(pageSize)
|
||||
dbSizeMB := float64(dbSizeBytes) / (1024 * 1024)
|
||||
sb.WriteString(fmt.Sprintf("Database size: %.2f MB\n", dbSizeMB))
|
||||
|
||||
// Active reminders
|
||||
var activeReminders int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM reminders WHERE fired = 0`).Scan(&activeReminders)
|
||||
sb.WriteString(fmt.Sprintf("Active reminders: %d\n", activeReminders))
|
||||
|
||||
// LLM status
|
||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
||||
if ollamaHost != "" {
|
||||
llmStatus := p.checkLLMStatus(ollamaHost)
|
||||
sb.WriteString(fmt.Sprintf("LLM status: %s\n", llmStatus))
|
||||
} else {
|
||||
sb.WriteString("LLM status: not configured\n")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *BotInfoPlugin) checkLLMStatus(ollamaHost string) string {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
apiURL := strings.TrimRight(ollamaHost, "/") + "/api/tags"
|
||||
|
||||
resp, err := client.Get(apiURL)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("offline (%s)", err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Sprintf("error (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "online (could not read response)"
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "online (could not parse response)"
|
||||
}
|
||||
|
||||
modelNames := make([]string, 0, len(result.Models))
|
||||
for _, m := range result.Models {
|
||||
modelNames = append(modelNames, m.Name)
|
||||
}
|
||||
|
||||
if len(modelNames) == 0 {
|
||||
return "online (no models loaded)"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("online (%d models: %s)", len(modelNames), strings.Join(modelNames, ", "))
|
||||
}
|
||||
441
internal/plugin/concerts.go
Normal file
441
internal/plugin/concerts.go
Normal file
@@ -0,0 +1,441 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// bandsintownEvent represents an event from the Bandsintown API.
|
||||
type bandsintownEvent struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
DateTime string `json:"datetime"`
|
||||
Title string `json:"title"`
|
||||
Venue struct {
|
||||
Name string `json:"name"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
Latitude string `json:"latitude"`
|
||||
Longitude string `json:"longitude"`
|
||||
} `json:"venue"`
|
||||
Lineup []string `json:"lineup"`
|
||||
OnSaleAt string `json:"on_sale_datetime"`
|
||||
Offers []struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
} `json:"offers"`
|
||||
}
|
||||
|
||||
// ConcertsPlugin provides concert lookups via Bandsintown.
|
||||
type ConcertsPlugin struct {
|
||||
Base
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
mu sync.Mutex
|
||||
cooldowns map[string]time.Time // keyed by userID+artist
|
||||
}
|
||||
|
||||
// NewConcertsPlugin creates a new ConcertsPlugin.
|
||||
func NewConcertsPlugin(client *mautrix.Client) *ConcertsPlugin {
|
||||
return &ConcertsPlugin{
|
||||
Base: NewBase(client),
|
||||
apiKey: os.Getenv("BANDSINTOWN_API_KEY"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
cooldowns: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) Name() string { return "concerts" }
|
||||
|
||||
func (p *ConcertsPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "concerts", Description: "Search upcoming concerts for an artist", Usage: "!concerts <artist>", Category: "Entertainment"},
|
||||
{Name: "concerts watch", Description: "Watch an artist for concert updates", Usage: "!concerts watch <artist>", Category: "Entertainment"},
|
||||
{Name: "concerts watching", Description: "List your watched artists", Usage: "!concerts watching", Category: "Entertainment"},
|
||||
{Name: "concerts unwatch", Description: "Stop watching an artist", Usage: "!concerts unwatch <artist>", Category: "Entertainment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) Init() error { return nil }
|
||||
|
||||
func (p *ConcertsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.IsCommand(ctx.Body, "concerts") {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := p.GetArgs(ctx.Body, "concerts")
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts <artist> | !concerts watch|watching|unwatch <artist>")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
sub := strings.ToLower(parts[0])
|
||||
|
||||
switch sub {
|
||||
case "watch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts watch <artist>")
|
||||
}
|
||||
return p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
||||
case "watching":
|
||||
return p.handleWatching(ctx)
|
||||
case "unwatch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts unwatch <artist>")
|
||||
}
|
||||
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
||||
default:
|
||||
return p.handleSearch(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) handleSearch(ctx MessageContext, artist string) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Concert lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
cooldownKey := string(ctx.Sender) + ":" + strings.ToLower(artist)
|
||||
p.mu.Lock()
|
||||
if last, ok := p.cooldowns[cooldownKey]; ok && time.Since(last) < 30*time.Second {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Please wait a moment before searching for this artist again.")
|
||||
}
|
||||
p.cooldowns[cooldownKey] = time.Now()
|
||||
p.mu.Unlock()
|
||||
|
||||
events, err := p.fetchEvents(artist)
|
||||
if err != nil {
|
||||
slog.Error("concerts: fetch failed", "artist", artist, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch concert data.")
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No upcoming concerts found for %s.", artist))
|
||||
}
|
||||
|
||||
// Show up to 5 events
|
||||
limit := 5
|
||||
if len(events) < limit {
|
||||
limit = len(events)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Upcoming concerts for %s:\n\n", artist))
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
ev := events[i]
|
||||
dateStr := p.formatEventDate(ev.DateTime)
|
||||
location := p.formatLocation(ev.Venue.City, ev.Venue.Region, ev.Venue.Country)
|
||||
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, dateStr))
|
||||
sb.WriteString(fmt.Sprintf(" Venue: %s\n", ev.Venue.Name))
|
||||
sb.WriteString(fmt.Sprintf(" Location: %s\n", location))
|
||||
if ev.URL != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Tickets: %s\n", ev.URL))
|
||||
}
|
||||
if i < limit-1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(events) > limit {
|
||||
sb.WriteString(fmt.Sprintf("\n...and %d more events.", len(events)-limit))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) fetchEvents(artist string) ([]bandsintownEvent, error) {
|
||||
d := db.Get()
|
||||
artistKey := strings.ToLower(artist)
|
||||
|
||||
// Check cache (6h TTL)
|
||||
var cached string
|
||||
var cachedAt int64
|
||||
err := d.QueryRow(
|
||||
`SELECT data, cached_at FROM concerts_cache WHERE artist = ?`, artistKey,
|
||||
).Scan(&cached, &cachedAt)
|
||||
if err == nil && time.Now().Unix()-cachedAt < 6*3600 {
|
||||
var events []bandsintownEvent
|
||||
if json.Unmarshal([]byte(cached), &events) == nil {
|
||||
return events, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
encoded := url.PathEscape(artist)
|
||||
apiURL := fmt.Sprintf("https://rest.bandsintown.com/artists/%s/events?app_id=%s&date=upcoming",
|
||||
encoded, p.apiKey)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("bandsintown returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var events []bandsintownEvent
|
||||
if err := json.NewDecoder(resp.Body).Decode(&events); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(events)
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO concerts_cache (artist, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(artist) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
artistKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("concerts: cache write", "err", err)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) formatEventDate(dateStr string) string {
|
||||
layouts := []string{
|
||||
"2006-01-02T15:04:05",
|
||||
time.RFC3339,
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, dateStr); err == nil {
|
||||
return t.Format("Mon, Jan 2, 2006 3:04 PM")
|
||||
}
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) formatLocation(city, region, country string) string {
|
||||
parts := []string{}
|
||||
if city != "" {
|
||||
parts = append(parts, city)
|
||||
}
|
||||
if region != "" {
|
||||
parts = append(parts, region)
|
||||
}
|
||||
if country != "" {
|
||||
parts = append(parts, country)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) handleWatch(ctx MessageContext, artist string) error {
|
||||
d := db.Get()
|
||||
artistKey := strings.ToLower(artist)
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO concert_watchlist (user_id, artist, room_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, artist) DO NOTHING`,
|
||||
string(ctx.Sender), artistKey, string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("concerts: watch add", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add artist to watchlist.")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Now watching %s for concert updates.", artist))
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) handleWatching(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT artist FROM concert_watchlist WHERE user_id = ? ORDER BY artist`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("concerts: watching list", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var artists []string
|
||||
for rows.Next() {
|
||||
var a string
|
||||
if err := rows.Scan(&a); err != nil {
|
||||
continue
|
||||
}
|
||||
artists = append(artists, a)
|
||||
}
|
||||
|
||||
if len(artists) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not watching any artists. Use !concerts watch <artist> to start.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Your Concert Watchlist:\n")
|
||||
for _, a := range artists {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", a))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) handleUnwatch(ctx MessageContext, artist string) error {
|
||||
d := db.Get()
|
||||
artistKey := strings.ToLower(artist)
|
||||
res, err := d.Exec(
|
||||
`DELETE FROM concert_watchlist WHERE user_id = ? AND artist = ?`,
|
||||
string(ctx.Sender), artistKey,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("concerts: unwatch", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove artist from watchlist.")
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is not in your watchlist.", artist))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Stopped watching %s.", artist))
|
||||
}
|
||||
|
||||
// PostWeeklyDigest sends a weekly concert digest to a room and DMs watchers about upcoming shows.
|
||||
// Intended to be called by the scheduler on Sundays.
|
||||
func (p *ConcertsPlugin) PostWeeklyDigest(roomID id.RoomID) {
|
||||
if p.apiKey == "" {
|
||||
slog.Warn("concerts: skipping weekly digest, no API key configured")
|
||||
return
|
||||
}
|
||||
|
||||
year, week := time.Now().UTC().ISOWeek()
|
||||
weekKey := fmt.Sprintf("%d-W%02d", year, week)
|
||||
if db.JobCompleted("concert_digest", weekKey) {
|
||||
slog.Info("concerts: already sent digest this week", "week", weekKey)
|
||||
return
|
||||
}
|
||||
// Mark completed at the end only if we succeed (not deferred)
|
||||
success := false
|
||||
defer func() {
|
||||
if success {
|
||||
db.MarkJobCompleted("concert_digest", weekKey)
|
||||
}
|
||||
}()
|
||||
|
||||
d := db.Get()
|
||||
|
||||
// Get all unique watched artists
|
||||
rows, err := d.Query(`SELECT DISTINCT artist FROM concert_watchlist`)
|
||||
if err != nil {
|
||||
slog.Error("concerts: weekly digest query", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
type watcherInfo struct {
|
||||
userID id.UserID
|
||||
roomID id.RoomID
|
||||
}
|
||||
|
||||
artistWatchers := make(map[string][]watcherInfo)
|
||||
var allArtists []string
|
||||
|
||||
// First collect all artists
|
||||
for rows.Next() {
|
||||
var artist string
|
||||
if err := rows.Scan(&artist); err != nil {
|
||||
continue
|
||||
}
|
||||
allArtists = append(allArtists, artist)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Get watchers per artist
|
||||
for _, artist := range allArtists {
|
||||
wRows, err := d.Query(
|
||||
`SELECT user_id, room_id FROM concert_watchlist WHERE artist = ?`, artist,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for wRows.Next() {
|
||||
var uid, rid string
|
||||
if err := wRows.Scan(&uid, &rid); err != nil {
|
||||
continue
|
||||
}
|
||||
artistWatchers[artist] = append(artistWatchers[artist], watcherInfo{
|
||||
userID: id.UserID(uid),
|
||||
roomID: id.RoomID(rid),
|
||||
})
|
||||
}
|
||||
wRows.Close()
|
||||
}
|
||||
|
||||
// For each artist, fetch events and notify watchers
|
||||
for artist, watchers := range artistWatchers {
|
||||
events, err := p.fetchEvents(artist)
|
||||
if err != nil {
|
||||
slog.Error("concerts: weekly digest fetch", "artist", artist, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter to events in the next 2 weeks
|
||||
now := time.Now()
|
||||
twoWeeks := now.Add(14 * 24 * time.Hour)
|
||||
var upcoming []bandsintownEvent
|
||||
for _, ev := range events {
|
||||
t, err := time.Parse("2006-01-02T15:04:05", ev.DateTime)
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339, ev.DateTime)
|
||||
}
|
||||
if err == nil && t.After(now) && t.Before(twoWeeks) {
|
||||
upcoming = append(upcoming, ev)
|
||||
}
|
||||
}
|
||||
|
||||
if len(upcoming) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build message
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Upcoming shows for %s (next 2 weeks):\n\n", artist))
|
||||
for i, ev := range upcoming {
|
||||
if i >= 5 {
|
||||
sb.WriteString(fmt.Sprintf("\n...and %d more.", len(upcoming)-5))
|
||||
break
|
||||
}
|
||||
dateStr := p.formatEventDate(ev.DateTime)
|
||||
location := p.formatLocation(ev.Venue.City, ev.Venue.Region, ev.Venue.Country)
|
||||
sb.WriteString(fmt.Sprintf("- %s @ %s (%s)\n", dateStr, ev.Venue.Name, location))
|
||||
}
|
||||
|
||||
msg := sb.String()
|
||||
for _, w := range watchers {
|
||||
if err := p.SendDM(w.userID, msg); err != nil {
|
||||
slog.Error("concerts: DM watcher", "user", w.userID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
success = true
|
||||
slog.Info("concerts: weekly digest completed")
|
||||
}
|
||||
272
internal/plugin/countdown.go
Normal file
272
internal/plugin/countdown.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
|
||||
var countdownLabelRe = regexp.MustCompile(`"([^"]+)"\s+(\d{4}-\d{2}-\d{2})`)
|
||||
|
||||
// CountdownPlugin manages countdown timers to specific dates.
|
||||
type CountdownPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewCountdownPlugin creates a new countdown plugin.
|
||||
func NewCountdownPlugin(client *mautrix.Client) *CountdownPlugin {
|
||||
return &CountdownPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) Name() string { return "countdown" }
|
||||
|
||||
func (p *CountdownPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "countdown", Description: "Manage countdowns to dates", Usage: "!countdown [add/private/mine/remove/id]", Category: "Personal"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) Init() error {
|
||||
// Auto-complete old countdowns
|
||||
p.completeOldCountdowns()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *CountdownPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.IsCommand(ctx.Body, "countdown") {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "countdown"))
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(strings.ToLower(args), "add "):
|
||||
return p.handleAdd(ctx, args[4:], true)
|
||||
case strings.HasPrefix(strings.ToLower(args), "private "):
|
||||
return p.handleAdd(ctx, args[8:], false)
|
||||
case strings.ToLower(args) == "mine":
|
||||
return p.handleMine(ctx)
|
||||
case strings.HasPrefix(strings.ToLower(args), "remove "):
|
||||
return p.handleRemove(ctx, strings.TrimSpace(args[7:]))
|
||||
case args == "":
|
||||
return p.handleList(ctx)
|
||||
default:
|
||||
// Try to parse as an ID
|
||||
if _, err := strconv.Atoi(args); err == nil {
|
||||
return p.handleShow(ctx, args)
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Usage: !countdown add \"<label>\" <YYYY-MM-DD> | private \"<label>\" <YYYY-MM-DD> | mine | remove <id> | [id]")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) handleAdd(ctx MessageContext, input string, public bool) error {
|
||||
matches := countdownLabelRe.FindStringSubmatch(input)
|
||||
if matches == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Usage: !countdown add \"<label>\" <YYYY-MM-DD>")
|
||||
}
|
||||
|
||||
label := matches[1]
|
||||
dateStr := matches[2]
|
||||
|
||||
_, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid date format. Use YYYY-MM-DD.")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
isPublic := 1
|
||||
if !public {
|
||||
isPublic = 0
|
||||
}
|
||||
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO countdowns (user_id, room_id, label, target_date, public) VALUES (?, ?, ?, ?, ?)`,
|
||||
string(ctx.Sender), string(ctx.RoomID), label, dateStr, isPublic,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("countdown: add", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add countdown.")
|
||||
}
|
||||
|
||||
visibility := "public"
|
||||
if !public {
|
||||
visibility = "private"
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Countdown added (%s): \"%s\" on %s", visibility, label, dateStr))
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) handleMine(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT id, label, target_date, public FROM countdowns
|
||||
WHERE user_id = ? AND completed = 0
|
||||
ORDER BY target_date ASC`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("countdown: mine", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your countdowns.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Your countdowns:\n\n")
|
||||
count := 0
|
||||
now := time.Now().UTC()
|
||||
|
||||
for rows.Next() {
|
||||
var cdID int
|
||||
var label, targetDate string
|
||||
var public int
|
||||
if err := rows.Scan(&cdID, &label, &targetDate, &public); err != nil {
|
||||
continue
|
||||
}
|
||||
t, _ := time.Parse("2006-01-02", targetDate)
|
||||
days := int(t.Sub(now).Hours() / 24)
|
||||
vis := ""
|
||||
if public == 0 {
|
||||
vis = " (private)"
|
||||
}
|
||||
status := fmt.Sprintf("%d days", days)
|
||||
if days < 0 {
|
||||
status = fmt.Sprintf("%d days ago", -days)
|
||||
} else if days == 0 {
|
||||
status = "TODAY!"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s — %s (%s)%s\n", cdID, label, targetDate, status, vis))
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no active countdowns.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) handleRemove(ctx MessageContext, idStr string) error {
|
||||
d := db.Get()
|
||||
result, err := d.Exec(
|
||||
`DELETE FROM countdowns WHERE id = ? AND user_id = ?`,
|
||||
idStr, string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("countdown: remove", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove countdown.")
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown not found or not yours.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown removed.")
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) handleList(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT id, label, target_date, user_id FROM countdowns
|
||||
WHERE public = 1 AND completed = 0
|
||||
ORDER BY target_date ASC LIMIT 20`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("countdown: list", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load countdowns.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Active countdowns:\n\n")
|
||||
count := 0
|
||||
now := time.Now().UTC()
|
||||
|
||||
for rows.Next() {
|
||||
var cdID int
|
||||
var label, targetDate, userID string
|
||||
if err := rows.Scan(&cdID, &label, &targetDate, &userID); err != nil {
|
||||
continue
|
||||
}
|
||||
t, _ := time.Parse("2006-01-02", targetDate)
|
||||
days := int(t.Sub(now).Hours() / 24)
|
||||
status := fmt.Sprintf("%d days", days)
|
||||
if days < 0 {
|
||||
status = fmt.Sprintf("%d days ago", -days)
|
||||
} else if days == 0 {
|
||||
status = "TODAY!"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s — %s (%s) by %s\n", cdID, label, targetDate, status, userID))
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active countdowns. Add one with !countdown add \"<label>\" <date>")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) handleShow(ctx MessageContext, idStr string) error {
|
||||
d := db.Get()
|
||||
var label, targetDate, userID string
|
||||
var public int
|
||||
err := d.QueryRow(
|
||||
`SELECT label, target_date, user_id, public FROM countdowns WHERE id = ? AND completed = 0`,
|
||||
idStr,
|
||||
).Scan(&label, &targetDate, &userID, &public)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown not found.")
|
||||
}
|
||||
|
||||
// Private countdowns only visible to owner
|
||||
if public == 0 && userID != string(ctx.Sender) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown not found.")
|
||||
}
|
||||
|
||||
t, _ := time.Parse("2006-01-02", targetDate)
|
||||
now := time.Now().UTC()
|
||||
diff := t.Sub(now)
|
||||
|
||||
var status string
|
||||
if diff < 0 {
|
||||
absDays := int(-diff.Hours()) / 24
|
||||
status = fmt.Sprintf("%d days ago", absDays)
|
||||
} else {
|
||||
days := int(diff.Hours()) / 24
|
||||
hours := int(diff.Hours()) % 24
|
||||
if days == 0 {
|
||||
status = fmt.Sprintf("%d hours remaining!", hours)
|
||||
} else {
|
||||
status = fmt.Sprintf("%d days and %d hours", days, hours)
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("\"%s\" — %s\n%s (by %s)", label, targetDate, status, userID)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) completeOldCountdowns() {
|
||||
d := db.Get()
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
|
||||
_, err := d.Exec(
|
||||
`UPDATE countdowns SET completed = 1 WHERE target_date < ? AND completed = 0`,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("countdown: auto-complete", "err", err)
|
||||
}
|
||||
}
|
||||
581
internal/plugin/fun.go
Normal file
581
internal/plugin/fun.go
Normal file
@@ -0,0 +1,581 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
|
||||
// cityTimezones maps common city names to IANA timezone strings.
|
||||
var cityTimezones = map[string]string{
|
||||
"new york": "America/New_York",
|
||||
"nyc": "America/New_York",
|
||||
"los angeles": "America/Los_Angeles",
|
||||
"la": "America/Los_Angeles",
|
||||
"chicago": "America/Chicago",
|
||||
"denver": "America/Denver",
|
||||
"london": "Europe/London",
|
||||
"paris": "Europe/Paris",
|
||||
"berlin": "Europe/Berlin",
|
||||
"tokyo": "Asia/Tokyo",
|
||||
"sydney": "Australia/Sydney",
|
||||
"dubai": "Asia/Dubai",
|
||||
"moscow": "Europe/Moscow",
|
||||
"mumbai": "Asia/Kolkata",
|
||||
"beijing": "Asia/Shanghai",
|
||||
"shanghai": "Asia/Shanghai",
|
||||
"singapore": "Asia/Singapore",
|
||||
"hong kong": "Asia/Hong_Kong",
|
||||
"seoul": "Asia/Seoul",
|
||||
"toronto": "America/Toronto",
|
||||
"vancouver": "America/Vancouver",
|
||||
"sao paulo": "America/Sao_Paulo",
|
||||
"mexico city": "America/Mexico_City",
|
||||
"cairo": "Africa/Cairo",
|
||||
"johannesburg": "Africa/Johannesburg",
|
||||
"auckland": "Pacific/Auckland",
|
||||
"honolulu": "Pacific/Honolulu",
|
||||
"hawaii": "Pacific/Honolulu",
|
||||
"anchorage": "America/Anchorage",
|
||||
"amsterdam": "Europe/Amsterdam",
|
||||
"rome": "Europe/Rome",
|
||||
"madrid": "Europe/Madrid",
|
||||
"lisbon": "Europe/Lisbon",
|
||||
"bangkok": "Asia/Bangkok",
|
||||
"jakarta": "Asia/Jakarta",
|
||||
"manila": "Asia/Manila",
|
||||
"taipei": "Asia/Taipei",
|
||||
"utc": "UTC",
|
||||
"gmt": "UTC",
|
||||
}
|
||||
|
||||
var eightBallResponses = []string{
|
||||
"It is certain.", "It is decidedly so.", "Without a doubt.",
|
||||
"Yes, definitely.", "You may rely on it.", "As I see it, yes.",
|
||||
"Most likely.", "Outlook good.", "Yes.", "Signs point to yes.",
|
||||
"Reply hazy, try again.", "Ask again later.", "Better not tell you now.",
|
||||
"Cannot predict now.", "Concentrate and ask again.",
|
||||
"Don't count on it.", "My reply is no.", "My sources say no.",
|
||||
"Outlook not so good.", "Very doubtful.",
|
||||
}
|
||||
|
||||
var twinbeeFacts = []string{
|
||||
"TwinBee was first released in 1985 by Konami for arcades in Japan.",
|
||||
"The TwinBee series is known as a 'cute 'em up' — a cute-themed shoot 'em up.",
|
||||
"TwinBee's main characters are sentient, bell-powered fighter ships.",
|
||||
"The bells in TwinBee change color when shot, granting different power-ups.",
|
||||
"WinBee, the pink ship, is piloted by Pastel in the later games.",
|
||||
"TwinBee Rainbow Bell Adventure is a rare platformer spinoff for the SNES.",
|
||||
"GwinBee is the green third ship, piloted by Mint in TwinBee Yahho!",
|
||||
"The TwinBee anime, 'TwinBee Paradise', aired as a radio drama in Japan.",
|
||||
"Detana!! TwinBee is considered one of the best entries and was a PC Engine hit.",
|
||||
"TwinBee Yahho! (1995) was the last major arcade release in the series.",
|
||||
"Pop'n TwinBee for the SNES featured a 2-player co-op mode with combined attacks.",
|
||||
"In TwinBee games, you punch clouds to release bells for power-ups.",
|
||||
"The TwinBee series inspired parts of Konami's Parodius games.",
|
||||
"Light and Pastel, the pilots of TwinBee and WinBee, are childhood friends.",
|
||||
"Dr. Cinnamon is the inventor who created the TwinBee ships.",
|
||||
"TwinBee 3: Poko Poko Daimaou featured an overworld map, mixing RPG and shooter elements.",
|
||||
}
|
||||
|
||||
var diceRe = regexp.MustCompile(`(?i)^(\d+)?d(\d+)([+-]\d+)?$`)
|
||||
|
||||
// FunPlugin provides various fun and utility commands.
|
||||
type FunPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewFunPlugin creates a new FunPlugin.
|
||||
func NewFunPlugin(client *mautrix.Client) *FunPlugin {
|
||||
return &FunPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *FunPlugin) Name() string { return "fun" }
|
||||
|
||||
func (p *FunPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "roll", Description: "Roll dice", Usage: "!roll [NdM+X]", Category: "Fun & Games"},
|
||||
{Name: "8ball", Description: "Magic 8-ball", Usage: "!8ball <question>", Category: "Fun & Games"},
|
||||
{Name: "coin", Description: "Flip a coin", Usage: "!coin", Category: "Fun & Games"},
|
||||
{Name: "time", Description: "World clock", Usage: "!time [city]", Category: "Lookup & Reference"},
|
||||
{Name: "hltb", Description: "HowLongToBeat lookup", Usage: "!hltb <game>", Category: "Lookup & Reference"},
|
||||
{Name: "twinbee", Description: "Random Twinbee lore/trivia", Usage: "!twinbee", Category: "Fun & Games"},
|
||||
{Name: "poll", Description: "Create a reaction poll", Usage: "!poll <question> | <option1> | <option2> ...", Category: "Fun & Games"},
|
||||
{Name: "weather", Description: "Weather lookup", Usage: "!weather <location>", Category: "Lookup & Reference"},
|
||||
{Name: "dadjoke", Description: "Random dad joke", Usage: "!dadjoke", Category: "Fun & Games"},
|
||||
{Name: "randomwiki", Description: "Random Wikipedia article", Usage: "!randomwiki", Category: "Fun & Games"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *FunPlugin) Init() error { return nil }
|
||||
|
||||
func (p *FunPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *FunPlugin) OnMessage(ctx MessageContext) error {
|
||||
switch {
|
||||
case p.IsCommand(ctx.Body, "roll"):
|
||||
return p.handleRoll(ctx)
|
||||
case p.IsCommand(ctx.Body, "8ball"):
|
||||
return p.handleEightBall(ctx)
|
||||
case p.IsCommand(ctx.Body, "coin"):
|
||||
return p.handleCoin(ctx)
|
||||
case p.IsCommand(ctx.Body, "time"):
|
||||
return p.handleTime(ctx)
|
||||
case p.IsCommand(ctx.Body, "hltb"):
|
||||
return p.handleHLTB(ctx)
|
||||
case p.IsCommand(ctx.Body, "twinbee"):
|
||||
return p.handleTwinbee(ctx)
|
||||
case p.IsCommand(ctx.Body, "poll"):
|
||||
return p.handlePoll(ctx)
|
||||
case p.IsCommand(ctx.Body, "weather"):
|
||||
return p.handleWeather(ctx)
|
||||
case p.IsCommand(ctx.Body, "dadjoke"):
|
||||
return p.handleDadJoke(ctx)
|
||||
case p.IsCommand(ctx.Body, "randomwiki"):
|
||||
return p.handleRandomWiki(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleRoll(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "roll"))
|
||||
|
||||
numDice := 1
|
||||
sides := 6
|
||||
modifier := 0
|
||||
|
||||
if args != "" {
|
||||
matches := diceRe.FindStringSubmatch(args)
|
||||
if matches == nil {
|
||||
return p.SendMessage(ctx.RoomID, "Invalid dice format. Use NdM+X (e.g., 2d6+3, d20, 4d8-1)")
|
||||
}
|
||||
if matches[1] != "" {
|
||||
numDice, _ = strconv.Atoi(matches[1])
|
||||
}
|
||||
sides, _ = strconv.Atoi(matches[2])
|
||||
if matches[3] != "" {
|
||||
modifier, _ = strconv.Atoi(matches[3])
|
||||
}
|
||||
}
|
||||
|
||||
if numDice < 1 || numDice > 100 {
|
||||
return p.SendMessage(ctx.RoomID, "Number of dice must be between 1 and 100.")
|
||||
}
|
||||
if sides < 2 || sides > 1000 {
|
||||
return p.SendMessage(ctx.RoomID, "Number of sides must be between 2 and 1000.")
|
||||
}
|
||||
|
||||
rolls := make([]int, numDice)
|
||||
total := 0
|
||||
for i := range numDice {
|
||||
rolls[i] = rand.IntN(sides) + 1
|
||||
total += rolls[i]
|
||||
}
|
||||
total += modifier
|
||||
|
||||
var result string
|
||||
if numDice == 1 && modifier == 0 {
|
||||
result = fmt.Sprintf("🎲 You rolled a **%d** (d%d)", total, sides)
|
||||
} else {
|
||||
rollStrs := make([]string, len(rolls))
|
||||
for i, r := range rolls {
|
||||
rollStrs[i] = strconv.Itoa(r)
|
||||
}
|
||||
modStr := ""
|
||||
if modifier > 0 {
|
||||
modStr = fmt.Sprintf("+%d", modifier)
|
||||
} else if modifier < 0 {
|
||||
modStr = fmt.Sprintf("%d", modifier)
|
||||
}
|
||||
result = fmt.Sprintf("🎲 Rolled %dd%d%s: [%s] = **%d**", numDice, sides, modStr, strings.Join(rollStrs, ", "), total)
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, result)
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleEightBall(ctx MessageContext) error {
|
||||
question := strings.TrimSpace(p.GetArgs(ctx.Body, "8ball"))
|
||||
if question == "" {
|
||||
return p.SendMessage(ctx.RoomID, "🎱 You need to ask a question!")
|
||||
}
|
||||
|
||||
response := eightBallResponses[rand.IntN(len(eightBallResponses))]
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🎱 %s", response))
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleCoin(ctx MessageContext) error {
|
||||
if rand.IntN(2) == 0 {
|
||||
return p.SendMessage(ctx.RoomID, "🪙 **Heads!**")
|
||||
}
|
||||
return p.SendMessage(ctx.RoomID, "🪙 **Tails!**")
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleTime(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(strings.ToLower(p.GetArgs(ctx.Body, "time")))
|
||||
if args == "" {
|
||||
// Show a few major cities
|
||||
cities := []string{"new york", "london", "tokyo", "sydney"}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🕐 World Clock:\n")
|
||||
for _, city := range cities {
|
||||
tz, _ := time.LoadLocation(cityTimezones[city])
|
||||
t := time.Now().In(tz)
|
||||
sb.WriteString(fmt.Sprintf(" • %s: %s\n", titleCase(city), t.Format("Mon Jan 2 15:04 MST")))
|
||||
}
|
||||
sb.WriteString("\nUse !time <city> for a specific location.")
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
|
||||
tzName, ok := cityTimezones[args]
|
||||
if !ok {
|
||||
// Try loading it as a raw IANA zone
|
||||
tzName = args
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(tzName)
|
||||
if err != nil {
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Unknown city or timezone: %s", args))
|
||||
}
|
||||
|
||||
t := time.Now().In(loc)
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🕐 %s: **%s**", args, t.Format("Monday, January 2, 2006 3:04 PM MST")))
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleHLTB(ctx MessageContext) error {
|
||||
gameName := strings.TrimSpace(p.GetArgs(ctx.Body, "hltb"))
|
||||
if gameName == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !hltb <game name>")
|
||||
}
|
||||
|
||||
// Check cache (24 hour TTL)
|
||||
var cachedData string
|
||||
var cachedAt int64
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT data, cached_at FROM hltb_cache WHERE game_name = ?`,
|
||||
strings.ToLower(gameName),
|
||||
).Scan(&cachedData, &cachedAt)
|
||||
if err == nil && time.Now().Unix()-cachedAt < 86400 {
|
||||
return p.SendMessage(ctx.RoomID, cachedData)
|
||||
}
|
||||
|
||||
// Scrape HLTB
|
||||
result, err := scrapeHLTB(gameName)
|
||||
if err != nil {
|
||||
slog.Error("hltb: scrape failed", "err", err, "game", gameName)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to look up that game on HowLongToBeat.")
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("No results found for \"%s\" on HowLongToBeat.", gameName))
|
||||
}
|
||||
|
||||
// Cache result
|
||||
_, _ = db.Get().Exec(
|
||||
`INSERT INTO hltb_cache (game_name, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(game_name) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
strings.ToLower(gameName), result, time.Now().Unix(), result, time.Now().Unix(),
|
||||
)
|
||||
|
||||
return p.SendMessage(ctx.RoomID, result)
|
||||
}
|
||||
|
||||
func scrapeHLTB(gameName string) (string, error) {
|
||||
payload := fmt.Sprintf(`{"searchType":"games","searchTerms":["%s"],"searchPage":1,"size":1,"searchOptions":{"games":{"userId":0,"platform":"","sortCategory":"popular","rangeCategory":"main","rangeTime":{"min":null,"max":null},"gameplay":{"perspective":"","flow":"","genre":"","subGenre":""},"rangeYear":{"min":"","max":""},"modifier":""},"users":{"sortCategory":"postcount"},"lists":{"sortCategory":"follows"},"filter":"","sort":0,"randomizer":0}}`,
|
||||
strings.ReplaceAll(gameName, `"`, `\"`))
|
||||
|
||||
req, err := http.NewRequest("POST", "https://howlongtobeat.com/api/search", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||
req.Header.Set("Referer", "https://howlongtobeat.com")
|
||||
req.Header.Set("Origin", "https://howlongtobeat.com")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []struct {
|
||||
GameName string `json:"game_name"`
|
||||
CompMain float64 `json:"comp_main"`
|
||||
CompPlus float64 `json:"comp_plus"`
|
||||
CompAll float64 `json:"comp_100"`
|
||||
ProfileDev string `json:"profile_dev"`
|
||||
ReleaseWorld int `json:"release_world"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(result.Data) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
game := result.Data[0]
|
||||
|
||||
formatTime := func(seconds float64) string {
|
||||
if seconds <= 0 {
|
||||
return "N/A"
|
||||
}
|
||||
hours := seconds / 3600.0
|
||||
if hours < 1 {
|
||||
return fmt.Sprintf("%.0f min", seconds/60.0)
|
||||
}
|
||||
return fmt.Sprintf("%.1f hours", hours)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🎮 **%s**\n", game.GameName))
|
||||
sb.WriteString(fmt.Sprintf(" Main Story: %s\n", formatTime(game.CompMain)))
|
||||
sb.WriteString(fmt.Sprintf(" Main + Extras: %s\n", formatTime(game.CompPlus)))
|
||||
sb.WriteString(fmt.Sprintf(" Completionist: %s\n", formatTime(game.CompAll)))
|
||||
if game.ProfileDev != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Developer: %s\n", game.ProfileDev))
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleTwinbee(ctx MessageContext) error {
|
||||
fact := twinbeeFacts[rand.IntN(len(twinbeeFacts))]
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🐝 **Twinbee Trivia:** %s", fact))
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handlePoll(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "poll")
|
||||
if args == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !poll <question> | <option1> | <option2> ...")
|
||||
}
|
||||
|
||||
parts := strings.Split(args, "|")
|
||||
if len(parts) < 3 {
|
||||
return p.SendMessage(ctx.RoomID, "A poll needs a question and at least 2 options, separated by |")
|
||||
}
|
||||
|
||||
if len(parts) > 11 {
|
||||
return p.SendMessage(ctx.RoomID, "Maximum 10 options allowed.")
|
||||
}
|
||||
|
||||
question := strings.TrimSpace(parts[0])
|
||||
options := make([]string, 0, len(parts)-1)
|
||||
for _, opt := range parts[1:] {
|
||||
trimmed := strings.TrimSpace(opt)
|
||||
if trimmed != "" {
|
||||
options = append(options, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
numberEmojis := []string{"1\uFE0F\u20E3", "2\uFE0F\u20E3", "3\uFE0F\u20E3", "4\uFE0F\u20E3", "5\uFE0F\u20E3",
|
||||
"6\uFE0F\u20E3", "7\uFE0F\u20E3", "8\uFE0F\u20E3", "9\uFE0F\u20E3", "\U0001F51F"}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📊 **Poll: %s**\n\n", question))
|
||||
for i, opt := range options {
|
||||
if i < len(numberEmojis) {
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n", numberEmojis[i], opt))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\nReact with the number to vote!")
|
||||
|
||||
if err := p.SendMessage(ctx.RoomID, sb.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleWeather(ctx MessageContext) error {
|
||||
location := strings.TrimSpace(p.GetArgs(ctx.Body, "weather"))
|
||||
if location == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !weather <location>")
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("OPENWEATHER_API_KEY")
|
||||
if apiKey == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Weather service is not configured (missing API key).")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=metric",
|
||||
url.QueryEscape(location), apiKey)
|
||||
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
slog.Error("weather: API request failed", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to fetch weather data.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Location \"%s\" not found.", location))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p.SendMessage(ctx.RoomID, "Failed to read weather data.")
|
||||
}
|
||||
|
||||
var weather struct {
|
||||
Name string `json:"name"`
|
||||
Main struct {
|
||||
Temp float64 `json:"temp"`
|
||||
FeelsLike float64 `json:"feels_like"`
|
||||
Humidity int `json:"humidity"`
|
||||
TempMin float64 `json:"temp_min"`
|
||||
TempMax float64 `json:"temp_max"`
|
||||
} `json:"main"`
|
||||
Weather []struct {
|
||||
Description string `json:"description"`
|
||||
} `json:"weather"`
|
||||
Wind struct {
|
||||
Speed float64 `json:"speed"`
|
||||
} `json:"wind"`
|
||||
Sys struct {
|
||||
Country string `json:"country"`
|
||||
} `json:"sys"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &weather); err != nil {
|
||||
slog.Error("weather: parse response", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to parse weather data.")
|
||||
}
|
||||
|
||||
desc := "N/A"
|
||||
if len(weather.Weather) > 0 {
|
||||
desc = weather.Weather[0].Description
|
||||
}
|
||||
|
||||
tempF := weather.Main.Temp*9.0/5.0 + 32
|
||||
feelsLikeF := weather.Main.FeelsLike*9.0/5.0 + 32
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🌤️ **Weather in %s, %s**\n", weather.Name, weather.Sys.Country))
|
||||
sb.WriteString(fmt.Sprintf(" Condition: %s\n", titleCase(desc)))
|
||||
sb.WriteString(fmt.Sprintf(" Temperature: %.1f°C (%.1f°F)\n", weather.Main.Temp, tempF))
|
||||
sb.WriteString(fmt.Sprintf(" Feels Like: %.1f°C (%.1f°F)\n", weather.Main.FeelsLike, feelsLikeF))
|
||||
sb.WriteString(fmt.Sprintf(" Humidity: %d%%\n", weather.Main.Humidity))
|
||||
sb.WriteString(fmt.Sprintf(" Wind: %.1f m/s\n", weather.Wind.Speed))
|
||||
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleDadJoke(ctx MessageContext) error {
|
||||
req, err := http.NewRequest("GET", "https://icanhazdadjoke.com/", nil)
|
||||
if err != nil {
|
||||
return p.SendMessage(ctx.RoomID, "Failed to fetch a dad joke.")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "GogoBee Matrix Bot")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("dadjoke: request failed", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to fetch a dad joke.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p.SendMessage(ctx.RoomID, "Failed to read dad joke response.")
|
||||
}
|
||||
|
||||
var joke struct {
|
||||
Joke string `json:"joke"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &joke); err != nil {
|
||||
return p.SendMessage(ctx.RoomID, "Failed to parse dad joke.")
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("😄 %s", joke.Joke))
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleRandomWiki(ctx MessageContext) error {
|
||||
req, err := http.NewRequest("GET", "https://en.wikipedia.org/api/rest_v1/page/random/summary", nil)
|
||||
if err != nil {
|
||||
return p.SendMessage(ctx.RoomID, "Failed to fetch a random article.")
|
||||
}
|
||||
req.Header.Set("User-Agent", "GogoBee Matrix Bot")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("randomwiki: request failed", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to fetch a random Wikipedia article.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p.SendMessage(ctx.RoomID, "Failed to read Wikipedia response.")
|
||||
}
|
||||
|
||||
var article struct {
|
||||
Title string `json:"title"`
|
||||
Extract string `json:"extract"`
|
||||
ContentURLs struct {
|
||||
Desktop struct {
|
||||
Page string `json:"page"`
|
||||
} `json:"desktop"`
|
||||
} `json:"content_urls"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &article); err != nil {
|
||||
return p.SendMessage(ctx.RoomID, "Failed to parse Wikipedia response.")
|
||||
}
|
||||
|
||||
extract := article.Extract
|
||||
if len(extract) > 300 {
|
||||
extract = extract[:300] + "..."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📖 **%s**\n", article.Title))
|
||||
sb.WriteString(extract)
|
||||
if article.ContentURLs.Desktop.Page != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n🔗 %s", article.ContentURLs.Desktop.Page))
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
|
||||
// titleCase capitalizes the first letter of each word (replacement for deprecated strings.Title).
|
||||
func titleCase(s string) string {
|
||||
prev := ' '
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(rune(prev)) || prev == ' ' {
|
||||
prev = r
|
||||
return unicode.ToTitle(r)
|
||||
}
|
||||
prev = r
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
430
internal/plugin/gaming.go
Normal file
430
internal/plugin/gaming.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// releaseListResponse is the top-level RAWG API response for game releases.
|
||||
type releaseListResponse struct {
|
||||
Count int `json:"count"`
|
||||
Results []releaseEntry `json:"results"`
|
||||
}
|
||||
|
||||
// releaseEntry is a game entry from the RAWG releases API.
|
||||
type releaseEntry struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Released string `json:"released"`
|
||||
Rating float64 `json:"rating"`
|
||||
Metacritic int `json:"metacritic"`
|
||||
Platforms []struct {
|
||||
Platform struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"platform"`
|
||||
} `json:"platforms"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
}
|
||||
|
||||
// GamingPlugin provides game release tracking using the RAWG API.
|
||||
type GamingPlugin struct {
|
||||
Base
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewGamingPlugin creates a new GamingPlugin.
|
||||
func NewGamingPlugin(client *mautrix.Client) *GamingPlugin {
|
||||
return &GamingPlugin{
|
||||
Base: NewBase(client),
|
||||
apiKey: os.Getenv("RAWG_API_KEY"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) Name() string { return "gaming" }
|
||||
|
||||
func (p *GamingPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "releases", Description: "Show game releases", Usage: "!releases [month|search <query>]", Category: "Entertainment"},
|
||||
{Name: "releasewatch", Description: "Manage your game release watchlist", Usage: "!releasewatch add|list|remove <game>", Category: "Entertainment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) Init() error { return nil }
|
||||
|
||||
func (p *GamingPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *GamingPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "releases") {
|
||||
return p.handleReleases(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "releasewatch") {
|
||||
return p.handleReleaseWatch(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostReleases posts this week's notable game releases to the given room.
|
||||
func (p *GamingPlugin) PostReleases(roomID id.RoomID) error {
|
||||
if p.apiKey == "" {
|
||||
slog.Warn("gaming: RAWG_API_KEY not set, skipping release post")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if already posted this week
|
||||
now := time.Now().UTC()
|
||||
year, week := now.ISOWeek()
|
||||
weekKey := fmt.Sprintf("%d-W%02d", year, week)
|
||||
if db.JobCompleted("releases", weekKey) {
|
||||
slog.Info("gaming: already posted releases this week", "week", weekKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
startDate := now.Format("2006-01-02")
|
||||
endDate := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||
|
||||
games, err := p.fetchReleases(startDate, endDate, "week")
|
||||
if err != nil {
|
||||
return fmt.Errorf("gaming: fetch releases: %w", err)
|
||||
}
|
||||
|
||||
if len(games) == 0 {
|
||||
slog.Info("gaming: no notable releases this week")
|
||||
db.MarkJobCompleted("releases", weekKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := p.formatReleases("This Week's Game Releases", games)
|
||||
if err := p.SendMessage(roomID, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db.MarkJobCompleted("releases", weekKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) handleReleases(ctx MessageContext) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Game release lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "releases"))
|
||||
|
||||
switch {
|
||||
case args == "" || strings.ToLower(args) == "week":
|
||||
return p.handleWeekReleases(ctx)
|
||||
case strings.ToLower(args) == "month":
|
||||
return p.handleMonthReleases(ctx)
|
||||
case strings.HasPrefix(strings.ToLower(args), "search "):
|
||||
query := strings.TrimSpace(args[7:])
|
||||
return p.handleSearchReleases(ctx, query)
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releases [month|search <query>]")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) handleWeekReleases(ctx MessageContext) error {
|
||||
now := time.Now().UTC()
|
||||
startDate := now.Format("2006-01-02")
|
||||
endDate := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||
|
||||
games, err := p.fetchReleases(startDate, endDate, "week")
|
||||
if err != nil {
|
||||
slog.Error("gaming: fetch week releases", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch game releases.")
|
||||
}
|
||||
|
||||
if len(games) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No notable game releases this week.")
|
||||
}
|
||||
|
||||
msg := p.formatReleases("This Week's Game Releases", games)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) handleMonthReleases(ctx MessageContext) error {
|
||||
now := time.Now().UTC()
|
||||
startDate := now.Format("2006-01-02")
|
||||
endDate := now.AddDate(0, 1, 0).Format("2006-01-02")
|
||||
|
||||
games, err := p.fetchReleases(startDate, endDate, "month")
|
||||
if err != nil {
|
||||
slog.Error("gaming: fetch month releases", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch game releases.")
|
||||
}
|
||||
|
||||
if len(games) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No notable game releases this month.")
|
||||
}
|
||||
|
||||
msg := p.formatReleases("This Month's Game Releases", games)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) handleSearchReleases(ctx MessageContext, query string) error {
|
||||
if query == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releases search <query>")
|
||||
}
|
||||
|
||||
games, err := p.searchUpcoming(query)
|
||||
if err != nil {
|
||||
slog.Error("gaming: search releases", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search game releases.")
|
||||
}
|
||||
|
||||
if len(games) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No upcoming releases found for \"%s\".", query))
|
||||
}
|
||||
|
||||
msg := p.formatReleases(fmt.Sprintf("Search Results: \"%s\"", query), games)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) fetchReleases(startDate, endDate, cacheKey string) ([]releaseEntry, error) {
|
||||
d := db.Get()
|
||||
fullKey := fmt.Sprintf("releases_%s_%s_%s", cacheKey, startDate, endDate)
|
||||
|
||||
// Check cache (1 hour TTL)
|
||||
var cached string
|
||||
var cachedAt int64
|
||||
err := d.QueryRow(
|
||||
`SELECT data, cached_at FROM releases_cache WHERE cache_key = ?`, fullKey,
|
||||
).Scan(&cached, &cachedAt)
|
||||
if err == nil && time.Now().UTC().Unix()-cachedAt < 3600 {
|
||||
var games []releaseEntry
|
||||
if json.Unmarshal([]byte(cached), &games) == nil {
|
||||
return games, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from RAWG
|
||||
apiURL := fmt.Sprintf(
|
||||
"https://api.rawg.io/api/games?key=%s&dates=%s,%s&ordering=-added&page_size=20",
|
||||
p.apiKey, startDate, endDate,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("RAWG returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawgResp releaseListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawgResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
games := rawgResp.Results
|
||||
|
||||
// Cache results
|
||||
data, _ := json.Marshal(games)
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO releases_cache (cache_key, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
fullKey, string(data), time.Now().UTC().Unix(), string(data), time.Now().UTC().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("gaming: cache write", "err", err)
|
||||
}
|
||||
|
||||
return games, nil
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) searchUpcoming(query string) ([]releaseEntry, error) {
|
||||
now := time.Now().UTC()
|
||||
startDate := now.Format("2006-01-02")
|
||||
endDate := now.AddDate(1, 0, 0).Format("2006-01-02")
|
||||
|
||||
apiURL := fmt.Sprintf(
|
||||
"https://api.rawg.io/api/games?key=%s&search=%s&dates=%s,%s&ordering=-added&page_size=10",
|
||||
p.apiKey, url.QueryEscape(query), startDate, endDate,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("RAWG search returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawgResp releaseListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawgResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rawgResp.Results, nil
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) formatReleases(title string, games []releaseEntry) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(title)
|
||||
sb.WriteString("\n")
|
||||
|
||||
limit := 15
|
||||
if len(games) < limit {
|
||||
limit = len(games)
|
||||
}
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
g := games[i]
|
||||
sb.WriteString(fmt.Sprintf("\n- %s", g.Name))
|
||||
if g.Released != "" {
|
||||
sb.WriteString(fmt.Sprintf(" (%s)", g.Released))
|
||||
}
|
||||
|
||||
var details []string
|
||||
if len(g.Platforms) > 0 {
|
||||
var plats []string
|
||||
for _, pl := range g.Platforms {
|
||||
plats = append(plats, pl.Platform.Name)
|
||||
}
|
||||
if len(plats) > 4 {
|
||||
plats = append(plats[:4], "...")
|
||||
}
|
||||
details = append(details, strings.Join(plats, ", "))
|
||||
}
|
||||
if len(g.Genres) > 0 {
|
||||
var genres []string
|
||||
for _, gn := range g.Genres {
|
||||
genres = append(genres, gn.Name)
|
||||
}
|
||||
details = append(details, strings.Join(genres, ", "))
|
||||
}
|
||||
if g.Metacritic > 0 {
|
||||
details = append(details, fmt.Sprintf("Metacritic: %d", g.Metacritic))
|
||||
}
|
||||
if len(details) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\n %s", strings.Join(details, " | ")))
|
||||
}
|
||||
}
|
||||
|
||||
if len(games) > limit {
|
||||
sb.WriteString(fmt.Sprintf("\n\n...and %d more.", len(games)-limit))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) handleReleaseWatch(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "releasewatch")
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releasewatch add|list|remove <game>")
|
||||
}
|
||||
|
||||
sub := strings.ToLower(parts[0])
|
||||
switch sub {
|
||||
case "add":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releasewatch add <game>")
|
||||
}
|
||||
return p.watchlistAdd(ctx, strings.TrimSpace(parts[1]))
|
||||
case "remove":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releasewatch remove <game>")
|
||||
}
|
||||
return p.watchlistRemove(ctx, strings.TrimSpace(parts[1]))
|
||||
case "list":
|
||||
return p.watchlistList(ctx)
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releasewatch add|list|remove <game>")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) watchlistAdd(ctx MessageContext, game string) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO release_watchlist (user_id, game_name, room_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, game_name) DO NOTHING`,
|
||||
string(ctx.Sender), game, string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("gaming: watchlist add", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Added \"%s\" to your release watchlist.", game))
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) watchlistRemove(ctx MessageContext, game string) error {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(
|
||||
`DELETE FROM release_watchlist WHERE user_id = ? AND game_name = ?`,
|
||||
string(ctx.Sender), game,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("gaming: watchlist remove", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("\"%s\" is not in your watchlist.", game))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed \"%s\" from your watchlist.", game))
|
||||
}
|
||||
|
||||
func (p *GamingPlugin) watchlistList(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT game_name FROM release_watchlist WHERE user_id = ? ORDER BY game_name`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("gaming: watchlist list", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var games []string
|
||||
for rows.Next() {
|
||||
var g string
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
continue
|
||||
}
|
||||
games = append(games, g)
|
||||
}
|
||||
|
||||
if len(games) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Your release watchlist is empty. Use !releasewatch add <game> to add games.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Your Release Watchlist:\n")
|
||||
for _, g := range games {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", g))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
574
internal/plugin/holidays.go
Normal file
574
internal/plugin/holidays.go
Normal file
@@ -0,0 +1,574 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Holiday represents a single holiday entry.
|
||||
type Holiday struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Country string `json:"country"`
|
||||
Type string `json:"type"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// holidaysDayData holds all holidays for a given date.
|
||||
type holidaysDayData struct {
|
||||
Holidays []Holiday `json:"holidays"`
|
||||
}
|
||||
|
||||
// calendarificResponse is the Calendarific API response structure.
|
||||
type calendarificResponse struct {
|
||||
Response struct {
|
||||
Holidays []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Country struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"country"`
|
||||
Type []string `json:"type"`
|
||||
Date struct {
|
||||
ISO string `json:"iso"`
|
||||
} `json:"date"`
|
||||
} `json:"holidays"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
// hebcalResponse is the HebCal API response structure.
|
||||
type hebcalResponse struct {
|
||||
Items []struct {
|
||||
Title string `json:"title"`
|
||||
Date string `json:"date"`
|
||||
Category string `json:"category"`
|
||||
Memo string `json:"memo"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
// aladhanResponse is the Aladhan API response for Hijri date conversion.
|
||||
type aladhanResponse struct {
|
||||
Data struct {
|
||||
Hijri struct {
|
||||
Date string `json:"date"`
|
||||
Month struct {
|
||||
En string `json:"en"`
|
||||
} `json:"month"`
|
||||
Year string `json:"year"`
|
||||
Day string `json:"day"`
|
||||
} `json:"hijri"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// HolidaysPlugin provides multi-calendar holiday announcements.
|
||||
type HolidaysPlugin struct {
|
||||
Base
|
||||
calendarificKey string
|
||||
countries []string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewHolidaysPlugin creates a new HolidaysPlugin.
|
||||
func NewHolidaysPlugin(client *mautrix.Client) *HolidaysPlugin {
|
||||
countries := []string{
|
||||
"US", "GB", "CA", "AU", "PT", "IN", "JP",
|
||||
"DE", "FR", "BR", "MX", "IT", "ES", "KR",
|
||||
"NL", "SE", "NO", "IE", "NZ", "ZA", "PH",
|
||||
}
|
||||
if env := os.Getenv("HOLIDAY_COUNTRIES"); env != "" {
|
||||
countries = nil
|
||||
for _, c := range strings.Split(env, ",") {
|
||||
c = strings.TrimSpace(strings.ToUpper(c))
|
||||
if c != "" {
|
||||
countries = append(countries, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &HolidaysPlugin{
|
||||
Base: NewBase(client),
|
||||
calendarificKey: os.Getenv("CALENDARIFIC_API_KEY"),
|
||||
countries: countries,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HolidaysPlugin) Name() string { return "holidays" }
|
||||
|
||||
func (p *HolidaysPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "holidays", Description: "Show today's holidays (or week/month)", Usage: "!holidays [week|month]", Category: "Holidays"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HolidaysPlugin) Init() error { return nil }
|
||||
|
||||
func (p *HolidaysPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *HolidaysPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "holidays") {
|
||||
return p.handleHolidays(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prefetch fetches today's holidays from all sources and stores them.
|
||||
func (p *HolidaysPlugin) Prefetch() error {
|
||||
today := time.Now().UTC()
|
||||
dateStr := today.Format("2006-01-02")
|
||||
|
||||
d := db.Get()
|
||||
var exists int
|
||||
err := d.QueryRow(`SELECT 1 FROM holidays_log WHERE date = ?`, dateStr).Scan(&exists)
|
||||
if err == nil {
|
||||
slog.Info("holidays: already fetched for today", "date", dateStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
var allHolidays []Holiday
|
||||
|
||||
// Fetch from Calendarific for multiple countries
|
||||
if p.calendarificKey != "" {
|
||||
for _, country := range p.countries {
|
||||
holidays, err := p.fetchCalendarific(today, country)
|
||||
if err != nil {
|
||||
slog.Error("holidays: calendarific fetch failed", "country", country, "err", err)
|
||||
} else {
|
||||
allHolidays = append(allHolidays, holidays...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from HebCal
|
||||
holidays, err := p.fetchHebCal(today)
|
||||
if err != nil {
|
||||
slog.Error("holidays: hebcal fetch failed", "err", err)
|
||||
} else {
|
||||
allHolidays = append(allHolidays, holidays...)
|
||||
}
|
||||
|
||||
// Fetch Islamic date info from Aladhan
|
||||
islamicInfo, err := p.fetchAladhan(today)
|
||||
if err != nil {
|
||||
slog.Error("holidays: aladhan fetch failed", "err", err)
|
||||
} else if islamicInfo != "" {
|
||||
allHolidays = append(allHolidays, Holiday{
|
||||
Name: "Islamic Date",
|
||||
Description: islamicInfo,
|
||||
Country: "International",
|
||||
Type: "islamic-calendar",
|
||||
Date: dateStr,
|
||||
})
|
||||
}
|
||||
|
||||
// Deduplicate by holiday name (case-insensitive)
|
||||
allHolidays = dedupeHolidays(allHolidays)
|
||||
|
||||
data := holidaysDayData{Holidays: allHolidays}
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("holidays: marshal: %w", err)
|
||||
}
|
||||
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO holidays_log (date, data, posted) VALUES (?, ?, 0)
|
||||
ON CONFLICT(date) DO UPDATE SET data = ?`,
|
||||
dateStr, string(jsonData), string(jsonData),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("holidays: store: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("holidays: prefetched", "date", dateStr, "count", len(allHolidays))
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostHolidays posts today's holidays to the given room.
|
||||
func (p *HolidaysPlugin) PostHolidays(roomID id.RoomID) error {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
var dataStr string
|
||||
var posted int
|
||||
err := d.QueryRow(
|
||||
`SELECT data, posted FROM holidays_log WHERE date = ?`, today,
|
||||
).Scan(&dataStr, &posted)
|
||||
if err == sql.ErrNoRows {
|
||||
slog.Warn("holidays: no entry for today, attempting prefetch", "date", today)
|
||||
if err := p.Prefetch(); err != nil {
|
||||
return fmt.Errorf("holidays: prefetch failed: %w", err)
|
||||
}
|
||||
err = d.QueryRow(
|
||||
`SELECT data, posted FROM holidays_log WHERE date = ?`, today,
|
||||
).Scan(&dataStr, &posted)
|
||||
if err != nil {
|
||||
return fmt.Errorf("holidays: still no entry after prefetch: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("holidays: query: %w", err)
|
||||
}
|
||||
|
||||
if posted == 1 {
|
||||
slog.Info("holidays: already posted today", "date", today)
|
||||
return nil
|
||||
}
|
||||
|
||||
var data holidaysDayData
|
||||
if err := json.Unmarshal([]byte(dataStr), &data); err != nil {
|
||||
return fmt.Errorf("holidays: unmarshal: %w", err)
|
||||
}
|
||||
|
||||
if len(data.Holidays) == 0 {
|
||||
slog.Info("holidays: no holidays to post today")
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := p.formatHolidays(today, data.Holidays)
|
||||
if err := p.SendMessage(roomID, msg); err != nil {
|
||||
return fmt.Errorf("holidays: send: %w", err)
|
||||
}
|
||||
|
||||
_, err = d.Exec(`UPDATE holidays_log SET posted = 1 WHERE date = ?`, today)
|
||||
if err != nil {
|
||||
slog.Error("holidays: mark posted", "err", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *HolidaysPlugin) handleHolidays(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "holidays"))
|
||||
|
||||
switch strings.ToLower(args) {
|
||||
case "week":
|
||||
return p.handleHolidaysRange(ctx, 7)
|
||||
case "month":
|
||||
return p.handleHolidaysRange(ctx, 30)
|
||||
default:
|
||||
return p.handleHolidaysToday(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HolidaysPlugin) handleHolidaysToday(ctx MessageContext) error {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
var dataStr string
|
||||
err := d.QueryRow(`SELECT data FROM holidays_log WHERE date = ?`, today).Scan(&dataStr)
|
||||
if err == sql.ErrNoRows {
|
||||
// Prefetch on demand
|
||||
if pfErr := p.Prefetch(); pfErr != nil {
|
||||
slog.Error("holidays: on-demand prefetch failed", "err", pfErr)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch holiday data. Try again later.")
|
||||
}
|
||||
err = d.QueryRow(`SELECT data FROM holidays_log WHERE date = ?`, today).Scan(&dataStr)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("holidays: query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch holiday data.")
|
||||
}
|
||||
|
||||
var data holidaysDayData
|
||||
if err := json.Unmarshal([]byte(dataStr), &data); err != nil {
|
||||
slog.Error("holidays: unmarshal", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse holiday data.")
|
||||
}
|
||||
|
||||
if len(data.Holidays) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No holidays today.")
|
||||
}
|
||||
|
||||
msg := p.formatHolidays(today, data.Holidays)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *HolidaysPlugin) handleHolidaysRange(ctx MessageContext, days int) error {
|
||||
d := db.Get()
|
||||
now := time.Now().UTC()
|
||||
|
||||
var sb strings.Builder
|
||||
label := "This Week"
|
||||
if days == 30 {
|
||||
label = "This Month"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Holidays — %s\n", label))
|
||||
|
||||
found := false
|
||||
for i := 0; i < days; i++ {
|
||||
date := now.AddDate(0, 0, i)
|
||||
dateStr := date.Format("2006-01-02")
|
||||
|
||||
var dataStr string
|
||||
err := d.QueryRow(`SELECT data FROM holidays_log WHERE date = ?`, dateStr).Scan(&dataStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var data holidaysDayData
|
||||
if json.Unmarshal([]byte(dataStr), &data) != nil || len(data.Holidays) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
found = true
|
||||
sb.WriteString(fmt.Sprintf("\n%s (%s):\n", dateStr, date.Format("Monday")))
|
||||
for _, h := range p.selectFeatured(data.Holidays) {
|
||||
sb.WriteString(fmt.Sprintf(" - %s", h.Name))
|
||||
if h.Country != "" && h.Country != "International" {
|
||||
sb.WriteString(fmt.Sprintf(" [%s]", h.Country))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No holiday data available for that range.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *HolidaysPlugin) formatHolidays(date string, holidays []Holiday) string {
|
||||
var sb strings.Builder
|
||||
t, _ := time.Parse("2006-01-02", date)
|
||||
sb.WriteString(fmt.Sprintf("Today's Holidays — %s\n", t.Format("Monday, January 2, 2006")))
|
||||
|
||||
featured := p.selectFeatured(holidays)
|
||||
for _, h := range featured {
|
||||
sb.WriteString(fmt.Sprintf("\n- %s", h.Name))
|
||||
if h.Country != "" && h.Country != "International" {
|
||||
sb.WriteString(fmt.Sprintf(" [%s]", h.Country))
|
||||
}
|
||||
if h.Description != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n %s", h.Description))
|
||||
}
|
||||
}
|
||||
|
||||
remaining := len(holidays) - len(featured)
|
||||
if remaining > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\n\n...and %d more. Use !holidays for the full list.", remaining))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// selectFeatured picks the most interesting holidays to highlight (up to 8).
|
||||
func (p *HolidaysPlugin) selectFeatured(holidays []Holiday) []Holiday {
|
||||
if len(holidays) <= 8 {
|
||||
return holidays
|
||||
}
|
||||
|
||||
// Prioritize national holidays and religious observances
|
||||
priorityTypes := map[string]bool{
|
||||
"national": true, "public": true, "religious": true,
|
||||
"observance": true, "jewish": true, "islamic-calendar": true,
|
||||
}
|
||||
|
||||
var featured, rest []Holiday
|
||||
for _, h := range holidays {
|
||||
if priorityTypes[strings.ToLower(h.Type)] {
|
||||
featured = append(featured, h)
|
||||
} else {
|
||||
rest = append(rest, h)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining slots
|
||||
for _, h := range rest {
|
||||
if len(featured) >= 8 {
|
||||
break
|
||||
}
|
||||
featured = append(featured, h)
|
||||
}
|
||||
|
||||
return featured
|
||||
}
|
||||
|
||||
// normalizeHolidayName strips common suffixes like "Day", "Eve" for dedup matching.
|
||||
func normalizeHolidayName(name string) string {
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
n = strings.TrimSuffix(n, " day")
|
||||
n = strings.TrimSuffix(n, "'s")
|
||||
n = strings.TrimSuffix(n, "s")
|
||||
return n
|
||||
}
|
||||
|
||||
// dedupeHolidays removes duplicate holidays by name or description.
|
||||
func dedupeHolidays(holidays []Holiday) []Holiday {
|
||||
seenName := make(map[string]int) // normalized name -> index in result
|
||||
seenDesc := make(map[string]int) // lowercase description -> index in result
|
||||
|
||||
var result []Holiday
|
||||
for _, h := range holidays {
|
||||
nameLower := normalizeHolidayName(h.Name)
|
||||
descLower := strings.ToLower(h.Description)
|
||||
|
||||
// Dedupe by name
|
||||
if idx, ok := seenName[nameLower]; ok {
|
||||
result[idx].Country = "International"
|
||||
continue
|
||||
}
|
||||
|
||||
// Dedupe by description (catches "Eight Hours Day" == "Labour Day" etc.)
|
||||
if descLower != "" {
|
||||
if idx, ok := seenDesc[descLower]; ok {
|
||||
result[idx].Country = "International"
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
seenName[nameLower] = len(result)
|
||||
if descLower != "" {
|
||||
seenDesc[descLower] = len(result)
|
||||
}
|
||||
result = append(result, h)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (p *HolidaysPlugin) fetchCalendarific(date time.Time, country string) ([]Holiday, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://calendarific.com/api/v2/holidays?api_key=%s&country=%s&year=%d&month=%d&day=%d",
|
||||
p.calendarificKey, country, date.Year(), int(date.Month()), date.Day(),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("calendarific returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var calResp calendarificResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&calResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Types to exclude — too regional to be interesting
|
||||
skipTypes := map[string]bool{
|
||||
"Local holiday": true,
|
||||
"Common local holiday": true,
|
||||
"Local observance": true,
|
||||
"Clock change/Daylight Saving Time": true,
|
||||
}
|
||||
|
||||
var holidays []Holiday
|
||||
for _, h := range calResp.Response.Holidays {
|
||||
hType := "other"
|
||||
if len(h.Type) > 0 {
|
||||
hType = h.Type[0]
|
||||
}
|
||||
if skipTypes[hType] {
|
||||
continue
|
||||
}
|
||||
holidays = append(holidays, Holiday{
|
||||
Name: h.Name,
|
||||
Description: h.Description,
|
||||
Country: h.Country.Name,
|
||||
Type: hType,
|
||||
Date: date.Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
|
||||
return holidays, nil
|
||||
}
|
||||
|
||||
func (p *HolidaysPlugin) fetchHebCal(date time.Time) ([]Holiday, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://www.hebcal.com/hebcal?v=1&cfg=json&year=%d&month=%d",
|
||||
date.Year(), int(date.Month()),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hebcal returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var hebResp hebcalResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&hebResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dateStr := date.Format("2006-01-02")
|
||||
var holidays []Holiday
|
||||
for _, item := range hebResp.Items {
|
||||
// HebCal dates are in YYYY-MM-DD format; match today
|
||||
if len(item.Date) >= 10 && item.Date[:10] == dateStr {
|
||||
holidays = append(holidays, Holiday{
|
||||
Name: item.Title,
|
||||
Description: item.Memo,
|
||||
Country: "International",
|
||||
Type: "jewish",
|
||||
Date: dateStr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return holidays, nil
|
||||
}
|
||||
|
||||
func (p *HolidaysPlugin) fetchAladhan(date time.Time) (string, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://api.aladhan.com/v1/gToH/%02d-%02d-%d",
|
||||
date.Day(), int(date.Month()), date.Year(),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("aladhan returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var aResp aladhanResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&aResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hijri := aResp.Data.Hijri
|
||||
if hijri.Day != "" && hijri.Month.En != "" && hijri.Year != "" {
|
||||
return fmt.Sprintf("%s %s, %s AH", hijri.Day, hijri.Month.En, hijri.Year), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
217
internal/plugin/howami.go
Normal file
217
internal/plugin/howami.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// HowAmIPlugin generates LLM-powered "roast" profiles of users.
|
||||
type HowAmIPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewHowAmIPlugin creates a new howami plugin.
|
||||
func NewHowAmIPlugin(client *mautrix.Client) *HowAmIPlugin {
|
||||
return &HowAmIPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HowAmIPlugin) Name() string { return "howami" }
|
||||
|
||||
func (p *HowAmIPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "howami", Description: "Get an LLM-generated roast profile", Usage: "!howami [@user]", Category: "LLM & Sentiment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HowAmIPlugin) Init() error { return nil }
|
||||
|
||||
func (p *HowAmIPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.IsCommand(ctx.Body, "howami") {
|
||||
return nil
|
||||
}
|
||||
|
||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
||||
if ollamaHost == "" || ollamaModel == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||
}
|
||||
|
||||
target := ctx.Sender
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "howami"))
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
if err := p.SendReply(ctx.RoomID, ctx.EventID, "Gathering your profile data..."); err != nil {
|
||||
slog.Error("howami: send thinking", "err", err)
|
||||
}
|
||||
|
||||
profile := p.gatherProfile(target)
|
||||
|
||||
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||
if botName == "" {
|
||||
botName = "GogoBee"
|
||||
}
|
||||
prompt := fmt.Sprintf(
|
||||
`You are a witty, playful community bot called %s. Based on the following user profile data, write a fun, lighthearted "roast" of this user in 3-5 sentences. Be creative, funny, and reference specific stats. Keep it friendly — no truly mean insults.
|
||||
|
||||
User: %s
|
||||
|
||||
Profile data:
|
||||
%s
|
||||
|
||||
Write the roast now. Do not include any preamble or explanation, just the roast text.`,
|
||||
botName, string(target), profile,
|
||||
)
|
||||
|
||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||
if err != nil {
|
||||
slog.Error("howami: ollama call", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate profile. LLM might be offline.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, response)
|
||||
}
|
||||
|
||||
func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
|
||||
d := db.Get()
|
||||
uid := string(userID)
|
||||
var sb strings.Builder
|
||||
|
||||
// XP and level
|
||||
var xp, level int
|
||||
if err := d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, uid).Scan(&xp, &level); err == nil {
|
||||
sb.WriteString(fmt.Sprintf("XP: %d, Level: %d\n", xp, level))
|
||||
}
|
||||
|
||||
// Message stats
|
||||
var totalMsgs, totalWords, totalEmojis, totalQuestions, totalLinks int
|
||||
if err := d.QueryRow(
|
||||
`SELECT total_messages, total_words, total_emojis, total_questions, total_links
|
||||
FROM user_stats WHERE user_id = ?`, uid,
|
||||
).Scan(&totalMsgs, &totalWords, &totalEmojis, &totalQuestions, &totalLinks); err == nil {
|
||||
sb.WriteString(fmt.Sprintf("Messages: %d, Words: %d, Emojis: %d, Questions: %d, Links: %d\n",
|
||||
totalMsgs, totalWords, totalEmojis, totalQuestions, totalLinks))
|
||||
}
|
||||
|
||||
// Achievements
|
||||
var achievementCount int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM achievements WHERE user_id = ?`, uid).Scan(&achievementCount)
|
||||
sb.WriteString(fmt.Sprintf("Achievements unlocked: %d\n", achievementCount))
|
||||
|
||||
// Sentiment stats
|
||||
var positive, negative, neutral int
|
||||
if err := d.QueryRow(
|
||||
`SELECT positive, negative, neutral FROM sentiment_stats WHERE user_id = ?`, uid,
|
||||
).Scan(&positive, &negative, &neutral); err == nil {
|
||||
sb.WriteString(fmt.Sprintf("Sentiment: %d positive, %d negative, %d neutral\n", positive, negative, neutral))
|
||||
}
|
||||
|
||||
// Profanity count
|
||||
var profanityCount int
|
||||
if err := d.QueryRow(`SELECT count FROM potty_mouth WHERE user_id = ?`, uid).Scan(&profanityCount); err == nil {
|
||||
sb.WriteString(fmt.Sprintf("Profanity count: %d\n", profanityCount))
|
||||
}
|
||||
|
||||
// Insult stats
|
||||
var timesInsulted, timesInsulting int
|
||||
if err := d.QueryRow(
|
||||
`SELECT times_insulted, times_insulting FROM insult_log WHERE user_id = ?`, uid,
|
||||
).Scan(×Insulted, ×Insulting); err == nil {
|
||||
sb.WriteString(fmt.Sprintf("Times insulted: %d, Times insulting: %d\n", timesInsulted, timesInsulting))
|
||||
}
|
||||
|
||||
// Trivia scores
|
||||
var correct, wrong, totalScore int
|
||||
var fastestMs sql.NullInt64
|
||||
if err := d.QueryRow(
|
||||
`SELECT COALESCE(SUM(correct),0), COALESCE(SUM(wrong),0), COALESCE(SUM(total_score),0), MIN(fastest_ms)
|
||||
FROM trivia_scores WHERE user_id = ?`, uid,
|
||||
).Scan(&correct, &wrong, &totalScore, &fastestMs); err == nil {
|
||||
sb.WriteString(fmt.Sprintf("Trivia: %d correct, %d wrong, score %d", correct, wrong, totalScore))
|
||||
if fastestMs.Valid && fastestMs.Int64 > 0 {
|
||||
sb.WriteString(fmt.Sprintf(", fastest: %dms", fastestMs.Int64))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// WOTD usage
|
||||
var wotdTotal int
|
||||
_ = d.QueryRow(`SELECT COALESCE(SUM(count),0) FROM wotd_usage WHERE user_id = ?`, uid).Scan(&wotdTotal)
|
||||
sb.WriteString(fmt.Sprintf("Word of the Day uses: %d\n", wotdTotal))
|
||||
|
||||
// Reputation
|
||||
var repXP int
|
||||
_ = d.QueryRow(
|
||||
`SELECT COALESCE(SUM(amount),0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`, uid,
|
||||
).Scan(&repXP)
|
||||
sb.WriteString(fmt.Sprintf("Reputation XP: %d (rep points: %d)\n", repXP, repXP/5))
|
||||
|
||||
// Streaks
|
||||
var activeDays int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM daily_activity WHERE user_id = ?`, uid).Scan(&activeDays)
|
||||
sb.WriteString(fmt.Sprintf("Active days: %d\n", activeDays))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// callOllama sends a prompt to the Ollama generate endpoint and returns the response.
|
||||
func callOllama(host, model, prompt string) (string, error) {
|
||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ollama request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.Response), nil
|
||||
}
|
||||
678
internal/plugin/llm_passive.go
Normal file
678
internal/plugin/llm_passive.go
Normal file
@@ -0,0 +1,678 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// profanityKeywords is a basic list used for pre-filtering.
|
||||
var profanityKeywords = []string{
|
||||
"fuck", "shit", "damn", "hell", "ass", "bitch", "crap", "dick",
|
||||
"bastard", "piss", "cock", "cunt", "douche", "dumbass", "idiot",
|
||||
"moron", "stupid", "stfu", "wtf", "lmao", "lmfao",
|
||||
}
|
||||
|
||||
var thanksPatternRe = regexp.MustCompile(`(?i)\b(thanks|thank\s+you|thankyou|thx|ty|tysm|tyvm|appreciate)\b`)
|
||||
var mentionRe = regexp.MustCompile(`@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+`)
|
||||
|
||||
// classificationResult holds the parsed LLM JSON response.
|
||||
type classificationResult struct {
|
||||
Sentiment string `json:"sentiment"`
|
||||
SentimentScore float64 `json:"sentiment_score"`
|
||||
Topics []string `json:"topics"`
|
||||
Profanity bool `json:"profanity"`
|
||||
ProfanitySeverity int `json:"profanity_severity"`
|
||||
InsultTarget string `json:"insult_target"`
|
||||
WOTDUsed bool `json:"wotd_used"`
|
||||
GratitudeTarget string `json:"gratitude_target"`
|
||||
}
|
||||
|
||||
// queueItem holds a message pending classification.
|
||||
type queueItem struct {
|
||||
UserID id.UserID
|
||||
RoomID id.RoomID
|
||||
EventID id.EventID
|
||||
Body string
|
||||
}
|
||||
|
||||
// LLMPassivePlugin classifies messages using Ollama and reacts accordingly.
|
||||
type LLMPassivePlugin struct {
|
||||
Base
|
||||
xp *XPPlugin
|
||||
ollamaHost string
|
||||
ollamaModel string
|
||||
sampleRate float64
|
||||
enabled bool
|
||||
|
||||
mu sync.Mutex
|
||||
queue []queueItem
|
||||
backoff time.Duration
|
||||
|
||||
httpClient *http.Client
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
|
||||
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin) *LLMPassivePlugin {
|
||||
host := os.Getenv("OLLAMA_HOST")
|
||||
model := os.Getenv("OLLAMA_MODEL")
|
||||
enabled := host != "" && model != ""
|
||||
|
||||
sampleRate := 0.15
|
||||
if v := os.Getenv("LLM_SAMPLE_RATE"); v != "" {
|
||||
if parsed, err := strconv.ParseFloat(v, 64); err == nil && parsed >= 0 && parsed <= 1 {
|
||||
sampleRate = parsed
|
||||
}
|
||||
}
|
||||
|
||||
p := &LLMPassivePlugin{
|
||||
Base: NewBase(client),
|
||||
xp: xp,
|
||||
ollamaHost: host,
|
||||
ollamaModel: model,
|
||||
sampleRate: sampleRate,
|
||||
enabled: enabled,
|
||||
backoff: 5 * time.Second,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) Name() string { return "llm_passive" }
|
||||
|
||||
func (p *LLMPassivePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "potty", Description: "Show profanity count for a user", Usage: "!potty [@user]", Category: "LLM & Sentiment"},
|
||||
{Name: "pottyboard", Description: "Top 10 most profane users", Usage: "!pottyboard", Category: "LLM & Sentiment"},
|
||||
{Name: "insults", Description: "Show insult stats for a user", Usage: "!insults [@user]", Category: "LLM & Sentiment"},
|
||||
{Name: "insultboard", Description: "Top 10 most insulted users", Usage: "!insultboard", Category: "LLM & Sentiment"},
|
||||
{Name: "sentiment", Description: "Show sentiment stats for a user", Usage: "!sentiment [@user]", Category: "LLM & Sentiment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) Init() error {
|
||||
if p.enabled {
|
||||
slog.Info("llm_passive: enabled", "host", p.ollamaHost, "model", p.ollamaModel, "sample_rate", p.sampleRate)
|
||||
go p.processQueue()
|
||||
} else {
|
||||
slog.Warn("llm_passive: disabled (OLLAMA_HOST or OLLAMA_MODEL not set)",
|
||||
"host", p.ollamaHost, "model", p.ollamaModel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
|
||||
// Handle commands
|
||||
if p.IsCommand(ctx.Body, "potty") {
|
||||
return p.handlePotty(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "pottyboard") {
|
||||
return p.handlePottyboard(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "insults") {
|
||||
return p.handleInsults(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "insultboard") {
|
||||
return p.handleInsultboard(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "sentiment") {
|
||||
return p.handleSentiment(ctx)
|
||||
}
|
||||
|
||||
if !p.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip command messages
|
||||
if ctx.IsCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pre-filter: only classify messages that match certain criteria
|
||||
if !p.shouldClassify(ctx.Body) {
|
||||
slog.Debug("llm_passive: message did not pass pre-filter", "body_len", len(ctx.Body))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enqueue for async classification
|
||||
slog.Debug("llm_passive: enqueuing message for classification", "user", ctx.Sender, "body_len", len(ctx.Body))
|
||||
p.mu.Lock()
|
||||
p.queue = append(p.queue, queueItem{
|
||||
UserID: ctx.Sender,
|
||||
RoomID: ctx.RoomID,
|
||||
EventID: ctx.EventID,
|
||||
Body: ctx.Body,
|
||||
})
|
||||
p.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldClassify applies pre-filtering heuristics.
|
||||
func (p *LLMPassivePlugin) shouldClassify(body string) bool {
|
||||
// Skip single-character messages (trivia answers, etc.)
|
||||
if len(strings.TrimSpace(body)) <= 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
lower := strings.ToLower(body)
|
||||
|
||||
// Check profanity keywords
|
||||
for _, kw := range profanityKeywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check mentions
|
||||
if mentionRe.MatchString(body) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check WOTD pattern (simple heuristic)
|
||||
if strings.Contains(lower, "wotd") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check non-ASCII
|
||||
for _, r := range body {
|
||||
if r > unicode.MaxASCII {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check thanks patterns
|
||||
if thanksPatternRe.MatchString(body) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Random sample of remaining messages (controlled by LLM_SAMPLE_RATE)
|
||||
if p.sampleRate >= 1.0 || rand.Float64() < p.sampleRate {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// processQueue processes the classification queue with backoff.
|
||||
func (p *LLMPassivePlugin) processQueue() {
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if len(p.queue) == 0 {
|
||||
p.mu.Unlock()
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
item := p.queue[0]
|
||||
p.queue = p.queue[1:]
|
||||
p.mu.Unlock()
|
||||
|
||||
slog.Debug("llm_passive: processing queued item", "user", item.UserID, "body_preview", truncate(item.Body, 50))
|
||||
err := p.classifyAndProcess(item)
|
||||
if err != nil {
|
||||
slog.Error("llm: classification failed", "err", err, "user", item.UserID)
|
||||
// Backoff on error
|
||||
p.mu.Lock()
|
||||
p.backoff *= 2
|
||||
if p.backoff > 5*time.Minute {
|
||||
p.backoff = 5 * time.Minute
|
||||
}
|
||||
p.mu.Unlock()
|
||||
time.Sleep(p.backoff)
|
||||
} else {
|
||||
// Reset backoff on success
|
||||
p.mu.Lock()
|
||||
p.backoff = 5 * time.Second
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// classifyAndProcess sends a message to Ollama and processes the result.
|
||||
func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
result, err := p.callOllama(item.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ollama call: %w", err)
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
|
||||
// Store classification
|
||||
topicsJSON, _ := json.Marshal(result.Topics)
|
||||
profanityInt := 0
|
||||
if result.Profanity {
|
||||
profanityInt = 1
|
||||
}
|
||||
wotdInt := 0
|
||||
if result.WOTDUsed {
|
||||
wotdInt = 1
|
||||
}
|
||||
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO llm_classifications (user_id, room_id, message_text, sentiment, sentiment_score, topics, profanity, profanity_severity, insult_target, wotd_used, gratitude_target)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
string(item.UserID), string(item.RoomID), item.Body,
|
||||
result.Sentiment, result.SentimentScore, string(topicsJSON),
|
||||
profanityInt, result.ProfanitySeverity, result.InsultTarget, wotdInt, result.GratitudeTarget,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("llm: store classification", "err", err)
|
||||
}
|
||||
|
||||
// Aggregate sentiment stats
|
||||
switch result.Sentiment {
|
||||
case "positive":
|
||||
_, _ = d.Exec(
|
||||
`INSERT INTO sentiment_stats (user_id, positive, total_score) VALUES (?, 1, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET positive = positive + 1, total_score = total_score + ?`,
|
||||
string(item.UserID), result.SentimentScore, result.SentimentScore,
|
||||
)
|
||||
case "negative":
|
||||
_, _ = d.Exec(
|
||||
`INSERT INTO sentiment_stats (user_id, negative, total_score) VALUES (?, 1, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET negative = negative + 1, total_score = total_score + ?`,
|
||||
string(item.UserID), result.SentimentScore, result.SentimentScore,
|
||||
)
|
||||
default:
|
||||
_, _ = d.Exec(
|
||||
`INSERT INTO sentiment_stats (user_id, neutral, total_score) VALUES (?, 1, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET neutral = neutral + 1, total_score = total_score + ?`,
|
||||
string(item.UserID), result.SentimentScore, result.SentimentScore,
|
||||
)
|
||||
}
|
||||
|
||||
// Track profanity with severity
|
||||
if result.Profanity {
|
||||
severity := result.ProfanitySeverity
|
||||
if severity < 1 {
|
||||
severity = 1
|
||||
}
|
||||
if severity > 3 {
|
||||
severity = 3
|
||||
}
|
||||
mildInc, modInc, scorchInc := 0, 0, 0
|
||||
switch severity {
|
||||
case 1:
|
||||
mildInc = 1
|
||||
case 2:
|
||||
modInc = 1
|
||||
case 3:
|
||||
scorchInc = 1
|
||||
}
|
||||
_, _ = d.Exec(
|
||||
`INSERT INTO potty_mouth (user_id, count, mild, moderate, scorching) VALUES (?, 1, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET count = count + 1, mild = mild + ?, moderate = moderate + ?, scorching = scorching + ?`,
|
||||
string(item.UserID), mildInc, modInc, scorchInc, mildInc, modInc, scorchInc,
|
||||
)
|
||||
}
|
||||
|
||||
// React if someone insults the bot
|
||||
if result.InsultTarget == string(p.Client.UserID) {
|
||||
botReactions := []string{
|
||||
"\U0001f595", // 🖕
|
||||
"\U0001f52a", // 🔪
|
||||
"\U0001f5e1", // 🗡️
|
||||
"\U0001fa78", // 🩸
|
||||
"\U0001f480", // 💀
|
||||
}
|
||||
emoji := botReactions[rand.Intn(len(botReactions))]
|
||||
_ = p.SendReact(item.RoomID, item.EventID, emoji)
|
||||
}
|
||||
|
||||
// Track insults
|
||||
if result.InsultTarget != "" {
|
||||
_, _ = d.Exec(
|
||||
`INSERT INTO insult_log (user_id, times_insulting) VALUES (?, 1)
|
||||
ON CONFLICT(user_id) DO UPDATE SET times_insulting = times_insulting + 1`,
|
||||
string(item.UserID),
|
||||
)
|
||||
_, _ = d.Exec(
|
||||
`INSERT INTO insult_log (user_id, times_insulted) VALUES (?, 1)
|
||||
ON CONFLICT(user_id) DO UPDATE SET times_insulted = times_insulted + 1`,
|
||||
result.InsultTarget,
|
||||
)
|
||||
}
|
||||
|
||||
// React with emojis based on classification
|
||||
if result.Sentiment == "positive" && result.SentimentScore > 0.7 {
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f44d") // thumbsup
|
||||
}
|
||||
if result.Sentiment == "negative" && result.SentimentScore < -0.7 {
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f44e") // thumbsdown
|
||||
}
|
||||
if result.Profanity {
|
||||
switch result.ProfanitySeverity {
|
||||
case 3:
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f92c") // 🤬
|
||||
case 2:
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f632") // 😲
|
||||
default:
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001fae3") // 🫣
|
||||
}
|
||||
}
|
||||
if result.WOTDUsed {
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // open book
|
||||
}
|
||||
if result.GratitudeTarget != "" {
|
||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // purple heart
|
||||
}
|
||||
|
||||
// Grant XP for gratitude
|
||||
if result.GratitudeTarget != "" && p.xp != nil {
|
||||
targetID := id.UserID(result.GratitudeTarget)
|
||||
if targetID != item.UserID {
|
||||
p.xp.GrantXP(targetID, 5, "gratitude")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ollamaRequest is the request body for the Ollama API.
|
||||
type ollamaRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
// ollamaResponse is the response from the Ollama API.
|
||||
type ollamaResponse struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
// callOllama sends a classification prompt to Ollama and parses the JSON result.
|
||||
func (p *LLMPassivePlugin) callOllama(messageText string) (*classificationResult, error) {
|
||||
prompt := fmt.Sprintf(`Classify the following chat message. Respond ONLY with valid JSON (no markdown, no explanation).
|
||||
|
||||
JSON schema:
|
||||
{
|
||||
"sentiment": "positive" | "negative" | "neutral",
|
||||
"sentiment_score": number between -1.0 and 1.0,
|
||||
"topics": ["topic1", "topic2"],
|
||||
"profanity": true | false,
|
||||
"profanity_severity": 0 | 1 | 2 | 3 (0=none, 1=mild e.g. damn/hell/crap, 2=moderate e.g. shit/ass/bitch, 3=scorching e.g. fuck/cunt and slurs),
|
||||
"insult_target": "" or "@user:server" if someone is being insulted,
|
||||
"wotd_used": true | false (if the message uses an unusual/sophisticated word),
|
||||
"gratitude_target": "" or "@user:server" if thanking someone
|
||||
}
|
||||
|
||||
Message: %s`, messageText)
|
||||
|
||||
reqBody := ollamaRequest{
|
||||
Model: p.ollamaModel,
|
||||
Prompt: prompt,
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(p.ollamaHost, "/") + "/api/generate"
|
||||
slog.Debug("llm_passive: calling ollama", "url", url, "model", p.ollamaModel)
|
||||
resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ollama request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("ollama status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var ollamaResp ollamaResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
||||
return nil, fmt.Errorf("decode ollama response: %w", err)
|
||||
}
|
||||
|
||||
result, err := parseClassification(ollamaResp.Response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse classification: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseClassification parses a JSON classification response with repair logic.
|
||||
func parseClassification(raw string) (*classificationResult, error) {
|
||||
cleaned := raw
|
||||
|
||||
// Remove markdown code fences
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
if strings.HasPrefix(cleaned, "```json") {
|
||||
cleaned = strings.TrimPrefix(cleaned, "```json")
|
||||
} else if strings.HasPrefix(cleaned, "```") {
|
||||
cleaned = strings.TrimPrefix(cleaned, "```")
|
||||
}
|
||||
if strings.HasSuffix(cleaned, "```") {
|
||||
cleaned = strings.TrimSuffix(cleaned, "```")
|
||||
}
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
|
||||
// Try to parse directly first
|
||||
var result classificationResult
|
||||
if err := json.Unmarshal([]byte(cleaned), &result); err == nil {
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Repair: replace single quotes with double quotes
|
||||
repaired := strings.ReplaceAll(cleaned, "'", "\"")
|
||||
|
||||
// Repair: remove trailing commas before } or ]
|
||||
trailingCommaRe := regexp.MustCompile(`,\s*([}\]])`)
|
||||
repaired = trailingCommaRe.ReplaceAllString(repaired, "$1")
|
||||
|
||||
if err := json.Unmarshal([]byte(repaired), &result); err != nil {
|
||||
return nil, fmt.Errorf("JSON parse failed after repair: %w (raw: %s)", err, raw)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// Command handlers
|
||||
|
||||
func (p *LLMPassivePlugin) handlePotty(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "potty")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var count, mild, moderate, scorching int
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(count, 0), COALESCE(mild, 0), COALESCE(moderate, 0), COALESCE(scorching, 0)
|
||||
FROM potty_mouth WHERE user_id = ?`,
|
||||
string(target),
|
||||
).Scan(&count, &mild, &moderate, &scorching)
|
||||
if err != nil {
|
||||
count = 0
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Profanity for %s: %s total (🫣 mild: %s, 😲 moderate: %s, 🤬 scorching: %s)",
|
||||
string(target), formatNumber(count), formatNumber(mild), formatNumber(moderate), formatNumber(scorching))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) handlePottyboard(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, count FROM potty_mouth ORDER BY count DESC LIMIT 10`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("llm: pottyboard query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load potty board.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Potty Mouth Board — Top 10\n\n")
|
||||
|
||||
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var count int
|
||||
if err := rows.Scan(&userID, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s — %s incidents\n", prefix, userID, formatNumber(count)))
|
||||
i++
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No profanity data yet.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) handleInsults(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "insults")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var insulted, insulting int
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(times_insulted, 0), COALESCE(times_insulting, 0) FROM insult_log WHERE user_id = ?`,
|
||||
string(target),
|
||||
).Scan(&insulted, &insulting)
|
||||
if err != nil {
|
||||
insulted = 0
|
||||
insulting = 0
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Insult stats for %s:\nTimes insulted: %s\nTimes insulting others: %s",
|
||||
string(target), formatNumber(insulted), formatNumber(insulting))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) handleInsultboard(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, times_insulted FROM insult_log ORDER BY times_insulted DESC LIMIT 10`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("llm: insultboard query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load insult board.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Most Insulted — Top 10\n\n")
|
||||
|
||||
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var count int
|
||||
if err := rows.Scan(&userID, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s — %s times\n", prefix, userID, formatNumber(count)))
|
||||
i++
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No insult data yet.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "sentiment")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var positive, negative, neutral int
|
||||
var totalScore float64
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(positive, 0), COALESCE(negative, 0), COALESCE(neutral, 0), COALESCE(total_score, 0)
|
||||
FROM sentiment_stats WHERE user_id = ?`,
|
||||
string(target),
|
||||
).Scan(&positive, &negative, &neutral, &totalScore)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No sentiment data for %s yet.", string(target)))
|
||||
}
|
||||
|
||||
total := positive + negative + neutral
|
||||
avgScore := 0.0
|
||||
if total > 0 {
|
||||
avgScore = totalScore / float64(total)
|
||||
}
|
||||
|
||||
mood := "neutral"
|
||||
if avgScore > 0.3 {
|
||||
mood = "mostly positive"
|
||||
} else if avgScore > 0.1 {
|
||||
mood = "leaning positive"
|
||||
} else if avgScore < -0.3 {
|
||||
mood = "mostly negative"
|
||||
} else if avgScore < -0.1 {
|
||||
mood = "leaning negative"
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Sentiment for %s:\n😊 Positive: %s 😐 Neutral: %s 😠 Negative: %s\nAverage mood: %.2f (%s)",
|
||||
string(target), formatNumber(positive), formatNumber(neutral), formatNumber(negative), avgScore, mood)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
334
internal/plugin/lookup.go
Normal file
334
internal/plugin/lookup.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// LookupPlugin provides Wikipedia, dictionary, Urban Dictionary, and translation lookups.
|
||||
type LookupPlugin struct {
|
||||
Base
|
||||
rateLimiter *RateLimitsPlugin
|
||||
}
|
||||
|
||||
// NewLookupPlugin creates a new lookup plugin.
|
||||
func NewLookupPlugin(client *mautrix.Client, rateLimiter *RateLimitsPlugin) *LookupPlugin {
|
||||
return &LookupPlugin{
|
||||
Base: NewBase(client),
|
||||
rateLimiter: rateLimiter,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LookupPlugin) Name() string { return "lookup" }
|
||||
|
||||
func (p *LookupPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "wiki", Description: "Look up a Wikipedia summary", Usage: "!wiki <topic>", Category: "Lookup & Reference"},
|
||||
{Name: "define", Description: "Look up a word definition", Usage: "!define <word>", Category: "Lookup & Reference"},
|
||||
{Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban <term>", Category: "Lookup & Reference"},
|
||||
{Name: "translate", Description: "Translate text (pt/es/fr/de/ja/ko/zh/ar/ru/it)", Usage: "!translate [lang] <text>", Category: "Lookup & Reference"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LookupPlugin) Init() error { return nil }
|
||||
|
||||
func (p *LookupPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *LookupPlugin) OnMessage(ctx MessageContext) error {
|
||||
switch {
|
||||
case p.IsCommand(ctx.Body, "wiki"):
|
||||
return p.handleWiki(ctx)
|
||||
case p.IsCommand(ctx.Body, "define"):
|
||||
return p.handleDefine(ctx)
|
||||
case p.IsCommand(ctx.Body, "urban"):
|
||||
return p.handleUrban(ctx)
|
||||
case p.IsCommand(ctx.Body, "translate"):
|
||||
return p.handleTranslate(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LookupPlugin) handleWiki(ctx MessageContext) error {
|
||||
topic := strings.TrimSpace(p.GetArgs(ctx.Body, "wiki"))
|
||||
if topic == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !wiki <topic>")
|
||||
}
|
||||
|
||||
encoded := url.PathEscape(strings.ReplaceAll(topic, " ", "_"))
|
||||
apiURL := fmt.Sprintf("https://en.wikipedia.org/api/rest_v1/page/summary/%s", encoded)
|
||||
|
||||
resp, err := httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
slog.Error("lookup: wiki request", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach Wikipedia.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No Wikipedia article found for \"%s\".", topic))
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Wikipedia returned an error.")
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read Wikipedia response.")
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Title string `json:"title"`
|
||||
Extract string `json:"extract"`
|
||||
ContentURLs struct {
|
||||
Desktop struct {
|
||||
Page string `json:"page"`
|
||||
} `json:"desktop"`
|
||||
} `json:"content_urls"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse Wikipedia response.")
|
||||
}
|
||||
|
||||
extract := result.Extract
|
||||
if len(extract) > 500 {
|
||||
extract = extract[:500] + "..."
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%s\n\n%s\n\n%s", result.Title, extract, result.ContentURLs.Desktop.Page)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
|
||||
word := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
|
||||
if word == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word>")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
|
||||
|
||||
resp, err := httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
slog.Error("lookup: define request", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.")
|
||||
}
|
||||
|
||||
var entries []struct {
|
||||
Word string `json:"word"`
|
||||
Meanings []struct {
|
||||
PartOfSpeech string `json:"partOfSpeech"`
|
||||
Definitions []struct {
|
||||
Definition string `json:"definition"`
|
||||
Example string `json:"example"`
|
||||
} `json:"definitions"`
|
||||
} `json:"meanings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
|
||||
}
|
||||
|
||||
entry := entries[0]
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
|
||||
|
||||
for i, meaning := range entry.Meanings {
|
||||
if i >= 3 { // Limit to 3 meanings
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
|
||||
for j, def := range meaning.Definitions {
|
||||
if j >= 2 { // Limit to 2 definitions per meaning
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
|
||||
if def.Example != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *LookupPlugin) handleUrban(ctx MessageContext) error {
|
||||
term := strings.TrimSpace(p.GetArgs(ctx.Body, "urban"))
|
||||
if term == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !urban <term>")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
termLower := strings.ToLower(term)
|
||||
|
||||
// Check cache (24h)
|
||||
var cachedData string
|
||||
var cachedAt int64
|
||||
err := d.QueryRow(
|
||||
`SELECT data, cached_at FROM urban_cache WHERE term = ?`,
|
||||
termLower,
|
||||
).Scan(&cachedData, &cachedAt)
|
||||
|
||||
now := time.Now().UTC().Unix()
|
||||
if err == nil && now-cachedAt < 86400 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, cachedData)
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
apiURL := fmt.Sprintf("https://api.urbandictionary.com/v0/define?term=%s", url.QueryEscape(term))
|
||||
|
||||
resp, err := httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
slog.Error("lookup: urban request", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach Urban Dictionary.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read Urban Dictionary response.")
|
||||
}
|
||||
|
||||
var result struct {
|
||||
List []struct {
|
||||
Word string `json:"word"`
|
||||
Definition string `json:"definition"`
|
||||
Example string `json:"example"`
|
||||
ThumbsUp int `json:"thumbs_up"`
|
||||
ThumbsDown int `json:"thumbs_down"`
|
||||
} `json:"list"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil || len(result.List) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No Urban Dictionary definition found for \"%s\".", term))
|
||||
}
|
||||
|
||||
entry := result.List[0]
|
||||
def := entry.Definition
|
||||
// Remove bracket notation used by Urban Dictionary
|
||||
def = strings.ReplaceAll(def, "[", "")
|
||||
def = strings.ReplaceAll(def, "]", "")
|
||||
if len(def) > 400 {
|
||||
def = def[:400] + "..."
|
||||
}
|
||||
|
||||
example := entry.Example
|
||||
example = strings.ReplaceAll(example, "[", "")
|
||||
example = strings.ReplaceAll(example, "]", "")
|
||||
if len(example) > 200 {
|
||||
example = example[:200] + "..."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Urban Dictionary: %s\n\n", entry.Word))
|
||||
sb.WriteString(fmt.Sprintf("%s\n", def))
|
||||
if example != "" {
|
||||
sb.WriteString(fmt.Sprintf("\nExample: %s\n", example))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n+%d / -%d", entry.ThumbsUp, entry.ThumbsDown))
|
||||
|
||||
msg := sb.String()
|
||||
|
||||
// Cache the result
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO urban_cache (term, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(term) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
termLower, msg, now, msg, now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("lookup: urban cache", "err", err)
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
var supportedLangs = map[string]bool{
|
||||
"pt": true, "es": true, "fr": true, "de": true, "ja": true,
|
||||
"ko": true, "zh": true, "ar": true, "ru": true, "it": true,
|
||||
}
|
||||
|
||||
func (p *LookupPlugin) handleTranslate(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "translate"))
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Usage: !translate [lang] <text>\nSupported: pt, es, fr, de, ja, ko, zh, ar, ru, it")
|
||||
}
|
||||
|
||||
ltURL := os.Getenv("LIBRETRANSLATE_URL")
|
||||
if ltURL == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Translation service is not configured.")
|
||||
}
|
||||
|
||||
// Rate limit: 10 translations per day
|
||||
if p.rateLimiter != nil && !p.rateLimiter.CheckLimit(ctx.Sender, "translate", 10) {
|
||||
remaining := p.rateLimiter.Remaining(ctx.Sender, "translate", 10)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Translation rate limit reached. %d remaining today.", remaining))
|
||||
}
|
||||
|
||||
// Parse lang code and text
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
targetLang := "es" // default
|
||||
text := args
|
||||
|
||||
if len(parts) >= 2 && supportedLangs[strings.ToLower(parts[0])] {
|
||||
targetLang = strings.ToLower(parts[0])
|
||||
text = parts[1]
|
||||
}
|
||||
|
||||
// Call LibreTranslate
|
||||
payload := fmt.Sprintf(`{"q":%q,"source":"auto","target":%q}`, text, targetLang)
|
||||
req, err := http.NewRequest("POST", strings.TrimRight(ltURL, "/")+"/translate", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to create translation request.")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("lookup: translate request", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach translation service.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read translation response.")
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TranslatedText string `json:"translatedText"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse translation response.")
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Translation error: %s", result.Error))
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Translation (-> %s):\n%s", targetLang, result.TranslatedText)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
198
internal/plugin/markov.go
Normal file
198
internal/plugin/markov.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// MarkovPlugin collects messages and generates trigram-based text.
|
||||
type MarkovPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewMarkovPlugin creates a new Markov chain plugin.
|
||||
func NewMarkovPlugin(client *mautrix.Client) *MarkovPlugin {
|
||||
return &MarkovPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) Name() string { return "markov" }
|
||||
|
||||
func (p *MarkovPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "markov", Description: "Generate Markov chain text from a user's messages", Usage: "!markov [@user|me]", Category: "Fun & Games"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) Init() error { return nil }
|
||||
|
||||
func (p *MarkovPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *MarkovPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "markov") {
|
||||
return p.handleMarkov(ctx)
|
||||
}
|
||||
|
||||
// Passive: collect non-command messages
|
||||
if !ctx.IsCommand {
|
||||
p.collectMessage(ctx.Sender, ctx.Body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectMessage stores a message in the markov_corpus, capping at 10,000 per user.
|
||||
func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
|
||||
// Skip very short messages
|
||||
if len(strings.Fields(text)) < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO markov_corpus (user_id, text) VALUES (?, ?)`,
|
||||
string(userID), text,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("markov: insert message", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Cap at 10,000 messages per user — delete oldest excess
|
||||
_, err = d.Exec(
|
||||
`DELETE FROM markov_corpus WHERE user_id = ? AND id NOT IN (
|
||||
SELECT id FROM markov_corpus WHERE user_id = ? ORDER BY id DESC LIMIT 10000
|
||||
)`,
|
||||
string(userID), string(userID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("markov: prune corpus", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
args := p.GetArgs(ctx.Body, "markov")
|
||||
|
||||
var targetUser id.UserID
|
||||
|
||||
switch {
|
||||
case args == "":
|
||||
// No argument — pick a random user from the corpus
|
||||
var randomUser string
|
||||
err := d.QueryRow(
|
||||
`SELECT user_id FROM markov_corpus ORDER BY RANDOM() LIMIT 1`,
|
||||
).Scan(&randomUser)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No Markov data available yet.")
|
||||
}
|
||||
targetUser = id.UserID(randomUser)
|
||||
case args == "me":
|
||||
targetUser = ctx.Sender
|
||||
default:
|
||||
// Treat as user ID
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
targetUser = id.UserID(cleaned)
|
||||
}
|
||||
|
||||
// Fetch corpus for the user
|
||||
rows, err := d.Query(
|
||||
`SELECT text FROM markov_corpus WHERE user_id = ?`,
|
||||
string(targetUser),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("markov: query corpus", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load Markov data.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var texts []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err != nil {
|
||||
continue
|
||||
}
|
||||
texts = append(texts, t)
|
||||
}
|
||||
|
||||
if len(texts) < 10 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Not enough data for %s (need at least 10 messages).", string(targetUser)))
|
||||
}
|
||||
|
||||
// Build trigram model and generate
|
||||
result := generateMarkov(texts, 50)
|
||||
if result == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("[%s]: %s", string(targetUser), result)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
// trigram key
|
||||
type trigramKey struct {
|
||||
w1, w2 string
|
||||
}
|
||||
|
||||
// generateMarkov builds a trigram model from texts and generates output.
|
||||
func generateMarkov(texts []string, maxWords int) string {
|
||||
chain := make(map[trigramKey][]string)
|
||||
var starters []trigramKey
|
||||
|
||||
for _, text := range texts {
|
||||
words := strings.Fields(text)
|
||||
if len(words) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
starters = append(starters, trigramKey{words[0], words[1]})
|
||||
|
||||
for i := 0; i < len(words)-2; i++ {
|
||||
key := trigramKey{words[i], words[i+1]}
|
||||
chain[key] = append(chain[key], words[i+2])
|
||||
}
|
||||
}
|
||||
|
||||
if len(starters) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Pick a random starter
|
||||
start := starters[rand.Intn(len(starters))]
|
||||
result := []string{start.w1, start.w2}
|
||||
|
||||
for len(result) < maxWords {
|
||||
key := trigramKey{result[len(result)-2], result[len(result)-1]}
|
||||
nextWords, ok := chain[key]
|
||||
if !ok || len(nextWords) == 0 {
|
||||
break
|
||||
}
|
||||
next := nextWords[rand.Intn(len(nextWords))]
|
||||
result = append(result, next)
|
||||
|
||||
// Stop at sentence-ending punctuation sometimes
|
||||
if len(result) > 8 && endsWithPunctuation(next) && rand.Float64() < 0.3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
||||
func endsWithPunctuation(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
last := s[len(s)-1]
|
||||
return last == '.' || last == '!' || last == '?'
|
||||
}
|
||||
620
internal/plugin/movies.go
Normal file
620
internal/plugin/movies.go
Normal file
@@ -0,0 +1,620 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// tmdbSearchResult represents a result from the TMDB search API.
|
||||
type tmdbSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"` // for TV shows
|
||||
Overview string `json:"overview"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
FirstAirDate string `json:"first_air_date"` // for TV shows
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
}
|
||||
|
||||
// tmdbSearchResponse is the TMDB search API response.
|
||||
type tmdbSearchResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []tmdbSearchResult `json:"results"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
// tmdbMovieDetail has additional movie details.
|
||||
type tmdbMovieDetail struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Runtime int `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Tagline string `json:"tagline"`
|
||||
Budget int64 `json:"budget"`
|
||||
Revenue int64 `json:"revenue"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
}
|
||||
|
||||
// tmdbTVDetail has additional TV show details.
|
||||
type tmdbTVDetail struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Status string `json:"status"`
|
||||
Tagline string `json:"tagline"`
|
||||
NumSeasons int `json:"number_of_seasons"`
|
||||
NumEpisodes int `json:"number_of_episodes"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
Networks []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"networks"`
|
||||
}
|
||||
|
||||
// tmdbUpcomingResponse is the TMDB upcoming movies response.
|
||||
type tmdbUpcomingResponse struct {
|
||||
Results []tmdbSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
// MoviesPlugin provides movie and TV lookups via TMDB.
|
||||
type MoviesPlugin struct {
|
||||
Base
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewMoviesPlugin creates a new MoviesPlugin.
|
||||
func NewMoviesPlugin(client *mautrix.Client) *MoviesPlugin {
|
||||
return &MoviesPlugin{
|
||||
Base: NewBase(client),
|
||||
apiKey: os.Getenv("TMDB_API_KEY"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) Name() string { return "movies" }
|
||||
|
||||
func (p *MoviesPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "movie", Description: "Search for movie info", Usage: "!movie <title>", Category: "Entertainment"},
|
||||
{Name: "tv", Description: "Search for TV show info", Usage: "!tv <title>", Category: "Entertainment"},
|
||||
{Name: "movie watch", Description: "Add a movie to your watchlist", Usage: "!movie watch <title>", Category: "Entertainment"},
|
||||
{Name: "movie watching", Description: "List your movie watchlist", Usage: "!movie watching", Category: "Entertainment"},
|
||||
{Name: "movie unwatch", Description: "Remove from watchlist by TMDB ID", Usage: "!movie unwatch <id>", Category: "Entertainment"},
|
||||
{Name: "upcoming movies", Description: "Show upcoming movies", Usage: "!upcoming movies", Category: "Entertainment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) Init() error { return nil }
|
||||
|
||||
func (p *MoviesPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *MoviesPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "movie") {
|
||||
return p.handleMovie(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "tv") {
|
||||
return p.handleTV(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "upcoming") {
|
||||
args := p.GetArgs(ctx.Body, "upcoming")
|
||||
if strings.ToLower(strings.TrimSpace(args)) == "movies" {
|
||||
return p.handleUpcoming(ctx)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleMovie(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "movie")
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Usage: !movie <title> | !movie watch|watching|unwatch <title|id>")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
sub := strings.ToLower(parts[0])
|
||||
|
||||
switch sub {
|
||||
case "watch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !movie watch <title>")
|
||||
}
|
||||
return p.handleWatchMovie(ctx, strings.TrimSpace(parts[1]))
|
||||
case "watching":
|
||||
return p.handleWatching(ctx)
|
||||
case "unwatch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !movie unwatch <id>")
|
||||
}
|
||||
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
||||
default:
|
||||
return p.handleMovieSearch(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleMovieSearch(ctx MessageContext, query string) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
detail, err := p.searchAndFetchMovie(query)
|
||||
if err != nil {
|
||||
slog.Error("movies: search failed", "query", query, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for movie.")
|
||||
}
|
||||
if detail == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No movies found for \"%s\".", query))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatMovie(detail))
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) searchAndFetchMovie(query string) (*tmdbMovieDetail, error) {
|
||||
encoded := url.QueryEscape(query)
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/movie?api_key=%s&query=%s&page=1",
|
||||
p.apiKey, encoded)
|
||||
|
||||
var searchResp tmdbSearchResponse
|
||||
if err := p.tmdbGet(apiURL, &searchResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(searchResp.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
movieID := searchResp.Results[0].ID
|
||||
return p.fetchMovieDetail(movieID)
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) fetchMovieDetail(movieID int) (*tmdbMovieDetail, error) {
|
||||
d := db.Get()
|
||||
|
||||
// Check cache (24h TTL)
|
||||
var cached string
|
||||
var cachedAt int64
|
||||
err := d.QueryRow(
|
||||
`SELECT data, cached_at FROM movie_cache WHERE tmdb_id = ?`, movieID,
|
||||
).Scan(&cached, &cachedAt)
|
||||
if err == nil && time.Now().Unix()-cachedAt < 24*3600 {
|
||||
var detail tmdbMovieDetail
|
||||
if json.Unmarshal([]byte(cached), &detail) == nil {
|
||||
return &detail, nil
|
||||
}
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/%d?api_key=%s", movieID, p.apiKey)
|
||||
var detail tmdbMovieDetail
|
||||
if err := p.tmdbGet(apiURL, &detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(detail)
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO movie_cache (tmdb_id, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(tmdb_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
movieID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("movies: cache write", "err", err)
|
||||
}
|
||||
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) formatMovie(m *tmdbMovieDetail) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s", m.Title))
|
||||
if m.ReleaseDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", m.ReleaseDate); err == nil {
|
||||
sb.WriteString(fmt.Sprintf(" (%d)", t.Year()))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
if m.Tagline != "" {
|
||||
sb.WriteString(fmt.Sprintf("\"%s\"\n", m.Tagline))
|
||||
}
|
||||
|
||||
if m.VoteAverage > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Rating: %.1f/10 (%d votes)\n", m.VoteAverage, m.VoteCount))
|
||||
}
|
||||
|
||||
if m.Runtime > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Runtime: %dh %dm\n", m.Runtime/60, m.Runtime%60))
|
||||
}
|
||||
|
||||
if m.Status != "" {
|
||||
sb.WriteString(fmt.Sprintf("Status: %s\n", m.Status))
|
||||
}
|
||||
|
||||
if len(m.Genres) > 0 {
|
||||
genres := make([]string, len(m.Genres))
|
||||
for i, g := range m.Genres {
|
||||
genres[i] = g.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||
}
|
||||
|
||||
if m.Overview != "" {
|
||||
overview := m.Overview
|
||||
if len(overview) > 400 {
|
||||
overview = overview[:400] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n%s\n", overview))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nhttps://www.themoviedb.org/movie/%d", m.ID))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleTV(ctx MessageContext) error {
|
||||
query := p.GetArgs(ctx.Body, "tv")
|
||||
if query == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !tv <title>")
|
||||
}
|
||||
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "TV lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
encoded := url.QueryEscape(query)
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/tv?api_key=%s&query=%s&page=1",
|
||||
p.apiKey, encoded)
|
||||
|
||||
var searchResp tmdbSearchResponse
|
||||
if err := p.tmdbGet(apiURL, &searchResp); err != nil {
|
||||
slog.Error("movies: tv search failed", "query", query, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for TV show.")
|
||||
}
|
||||
|
||||
if len(searchResp.Results) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No TV shows found for \"%s\".", query))
|
||||
}
|
||||
|
||||
tvID := searchResp.Results[0].ID
|
||||
detail, err := p.fetchTVDetail(tvID)
|
||||
if err != nil {
|
||||
slog.Error("movies: tv detail fetch failed", "id", tvID, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch TV show details.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatTV(detail))
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) fetchTVDetail(tvID int) (*tmdbTVDetail, error) {
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%d?api_key=%s", tvID, p.apiKey)
|
||||
var detail tmdbTVDetail
|
||||
if err := p.tmdbGet(apiURL, &detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) formatTV(tv *tmdbTVDetail) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s", tv.Name))
|
||||
if tv.FirstAirDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", tv.FirstAirDate); err == nil {
|
||||
sb.WriteString(fmt.Sprintf(" (%d)", t.Year()))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
if tv.Tagline != "" {
|
||||
sb.WriteString(fmt.Sprintf("\"%s\"\n", tv.Tagline))
|
||||
}
|
||||
|
||||
if tv.VoteAverage > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Rating: %.1f/10 (%d votes)\n", tv.VoteAverage, tv.VoteCount))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Seasons: %d | Episodes: %d\n", tv.NumSeasons, tv.NumEpisodes))
|
||||
|
||||
if tv.Status != "" {
|
||||
sb.WriteString(fmt.Sprintf("Status: %s\n", tv.Status))
|
||||
}
|
||||
|
||||
if len(tv.Genres) > 0 {
|
||||
genres := make([]string, len(tv.Genres))
|
||||
for i, g := range tv.Genres {
|
||||
genres[i] = g.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||
}
|
||||
|
||||
if len(tv.Networks) > 0 {
|
||||
networks := make([]string, len(tv.Networks))
|
||||
for i, n := range tv.Networks {
|
||||
networks[i] = n.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Networks: %s\n", strings.Join(networks, ", ")))
|
||||
}
|
||||
|
||||
if tv.Overview != "" {
|
||||
overview := tv.Overview
|
||||
if len(overview) > 400 {
|
||||
overview = overview[:400] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n%s\n", overview))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nhttps://www.themoviedb.org/tv/%d", tv.ID))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleWatchMovie(ctx MessageContext, title string) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
encoded := url.QueryEscape(title)
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/movie?api_key=%s&query=%s&page=1",
|
||||
p.apiKey, encoded)
|
||||
|
||||
var searchResp tmdbSearchResponse
|
||||
if err := p.tmdbGet(apiURL, &searchResp); err != nil {
|
||||
slog.Error("movies: watch search failed", "title", title, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for movie.")
|
||||
}
|
||||
|
||||
if len(searchResp.Results) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No movies found for \"%s\".", title))
|
||||
}
|
||||
|
||||
result := searchResp.Results[0]
|
||||
d := db.Get()
|
||||
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO movie_watchlist (user_id, tmdb_id, title, media_type, room_id) VALUES (?, ?, ?, 'movie', ?)
|
||||
ON CONFLICT(user_id, tmdb_id) DO NOTHING`,
|
||||
string(ctx.Sender), result.ID, result.Title, string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("movies: watchlist add", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Added \"%s\" (TMDB ID: %d) to your movie watchlist.", result.Title, result.ID))
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleWatching(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT tmdb_id, title, media_type FROM movie_watchlist WHERE user_id = ? ORDER BY title`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("movies: watching list", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Your Movie/TV Watchlist:\n\n")
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var tmdbID int
|
||||
var title, mediaType string
|
||||
if err := rows.Scan(&tmdbID, &title, &mediaType); err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s (%s)\n", tmdbID, title, mediaType))
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Your movie watchlist is empty. Use !movie watch <title> to add movies.")
|
||||
}
|
||||
|
||||
sb.WriteString("\nUse !movie unwatch <id> to remove an entry.")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleUnwatch(ctx MessageContext, idStr string) error {
|
||||
tmdbID, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Please provide a valid TMDB ID number. Check !movie watching for your list.")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
res, err := d.Exec(
|
||||
`DELETE FROM movie_watchlist WHERE user_id = ? AND tmdb_id = ?`,
|
||||
string(ctx.Sender), tmdbID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("movies: unwatch", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("TMDB ID %d is not in your watchlist.", tmdbID))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed TMDB ID %d from your watchlist.", tmdbID))
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) handleUpcoming(ctx MessageContext) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/upcoming?api_key=%s®ion=US&page=1", p.apiKey)
|
||||
|
||||
var resp tmdbUpcomingResponse
|
||||
if err := p.tmdbGet(apiURL, &resp); err != nil {
|
||||
slog.Error("movies: upcoming fetch failed", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch upcoming movies.")
|
||||
}
|
||||
|
||||
if len(resp.Results) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No upcoming movies found.")
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if len(resp.Results) < limit {
|
||||
limit = len(resp.Results)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Upcoming Movies:\n\n")
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
r := resp.Results[i]
|
||||
title := r.Title
|
||||
if title == "" {
|
||||
title = r.Name
|
||||
}
|
||||
dateStr := r.ReleaseDate
|
||||
if dateStr == "" {
|
||||
dateStr = "TBA"
|
||||
}
|
||||
ratingStr := ""
|
||||
if r.VoteAverage > 0 {
|
||||
ratingStr = fmt.Sprintf(" - %.1f/10", r.VoteAverage)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s (%s)%s\n", i+1, title, dateStr, ratingStr))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) tmdbGet(apiURL string, target interface{}) error {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tmdb returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
// PostDailyReleases checks release dates against watchlist and DMs users about releases.
|
||||
// Intended to be called by the scheduler daily.
|
||||
func (p *MoviesPlugin) PostDailyReleases(roomID id.RoomID) {
|
||||
if p.apiKey == "" {
|
||||
slog.Warn("movies: skipping daily releases, no API key configured")
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if db.JobCompleted("movie_releases", today) {
|
||||
slog.Info("movies: already sent daily releases", "date", today)
|
||||
return
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if success {
|
||||
db.MarkJobCompleted("movie_releases", today)
|
||||
}
|
||||
}()
|
||||
|
||||
d := db.Get()
|
||||
|
||||
// Get all unique TMDB IDs from watchlists
|
||||
rows, err := d.Query(`SELECT DISTINCT tmdb_id, title, media_type FROM movie_watchlist`)
|
||||
if err != nil {
|
||||
slog.Error("movies: daily releases query", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
type watchedItem struct {
|
||||
tmdbID int
|
||||
title string
|
||||
mediaType string
|
||||
}
|
||||
var items []watchedItem
|
||||
for rows.Next() {
|
||||
var item watchedItem
|
||||
if err := rows.Scan(&item.tmdbID, &item.title, &item.mediaType); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, item := range items {
|
||||
if item.mediaType != "movie" {
|
||||
continue
|
||||
}
|
||||
|
||||
detail, err := p.fetchMovieDetail(item.tmdbID)
|
||||
if err != nil {
|
||||
slog.Error("movies: daily release fetch", "tmdb_id", item.tmdbID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if detail.ReleaseDate != today {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find watchers
|
||||
wRows, err := d.Query(
|
||||
`SELECT user_id FROM movie_watchlist WHERE tmdb_id = ?`, item.tmdbID,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Release day! \"%s\" is out today!\n%s\nhttps://www.themoviedb.org/movie/%d",
|
||||
detail.Title, detail.Tagline, detail.ID)
|
||||
|
||||
for wRows.Next() {
|
||||
var uid string
|
||||
if err := wRows.Scan(&uid); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := p.SendDM(id.UserID(uid), msg); err != nil {
|
||||
slog.Error("movies: DM watcher", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
wRows.Close()
|
||||
}
|
||||
|
||||
success = true
|
||||
slog.Info("movies: daily releases check completed")
|
||||
}
|
||||
221
internal/plugin/plugin.go
Normal file
221
internal/plugin/plugin.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/util"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// CommandDef describes a bot command for help/discovery.
|
||||
type CommandDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Usage string
|
||||
Category string
|
||||
AdminOnly bool
|
||||
}
|
||||
|
||||
// MessageContext holds the context for a message event.
|
||||
type MessageContext struct {
|
||||
RoomID id.RoomID
|
||||
EventID id.EventID
|
||||
Sender id.UserID
|
||||
Body string
|
||||
IsCommand bool // true if the message starts with the command prefix
|
||||
Event *event.Event
|
||||
}
|
||||
|
||||
// ReactionContext holds the context for a reaction event.
|
||||
type ReactionContext struct {
|
||||
RoomID id.RoomID
|
||||
EventID id.EventID
|
||||
Sender id.UserID
|
||||
TargetEvent id.EventID
|
||||
Emoji string
|
||||
Event *event.Event
|
||||
}
|
||||
|
||||
// Plugin is the interface all plugins must implement.
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Commands() []CommandDef
|
||||
OnMessage(ctx MessageContext) error
|
||||
OnReaction(ctx ReactionContext) error
|
||||
Init() error
|
||||
}
|
||||
|
||||
// Base provides common helpers for plugin implementations.
|
||||
type Base struct {
|
||||
Client *mautrix.Client
|
||||
Prefix string
|
||||
}
|
||||
|
||||
// NewBase creates a Base with default prefix "!".
|
||||
func NewBase(client *mautrix.Client) Base {
|
||||
return Base{Client: client, Prefix: "!"}
|
||||
}
|
||||
|
||||
// IsCommand checks if body matches prefix+command.
|
||||
func (b *Base) IsCommand(body, command string) bool {
|
||||
return util.IsCommand(body, b.Prefix, command)
|
||||
}
|
||||
|
||||
// GetArgs returns the argument string after the command.
|
||||
func (b *Base) GetArgs(body, command string) string {
|
||||
return util.GetArgs(body, b.Prefix, command)
|
||||
}
|
||||
|
||||
// IsAdmin checks if a user ID is in the admin list.
|
||||
func (b *Base) IsAdmin(userID id.UserID) bool {
|
||||
admins := os.Getenv("ADMIN_USERS")
|
||||
if admins == "" {
|
||||
return false
|
||||
}
|
||||
for _, a := range strings.Split(admins, ",") {
|
||||
if strings.TrimSpace(a) == string(userID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SendMessage sends a plain text message to a room.
|
||||
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
}
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
slog.Error("failed to send message", "room", roomID, "err", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SendMessageID sends a plain text message and returns the event ID.
|
||||
func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
}
|
||||
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
slog.Error("failed to send message", "room", roomID, "err", err)
|
||||
return "", err
|
||||
}
|
||||
return resp.EventID, nil
|
||||
}
|
||||
|
||||
// SendThread sends a message in a thread rooted at threadID.
|
||||
func (b *Base) SendThread(roomID id.RoomID, threadID id.EventID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
RelatesTo: &event.RelatesTo{
|
||||
Type: event.RelThread,
|
||||
EventID: threadID,
|
||||
InReplyTo: &event.InReplyTo{
|
||||
EventID: threadID,
|
||||
},
|
||||
IsFallingBack: true,
|
||||
},
|
||||
}
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
slog.Error("failed to send thread message", "room", roomID, "err", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SendNotice sends an m.notice message to a room.
|
||||
func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgNotice,
|
||||
Body: text,
|
||||
}
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendReply sends a reply to a specific event.
|
||||
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
RelatesTo: &event.RelatesTo{
|
||||
InReplyTo: &event.InReplyTo{
|
||||
EventID: eventID,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
slog.Error("failed to send reply", "room", roomID, "err", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SendHTML sends an HTML-formatted message.
|
||||
func (b *Base) SendHTML(roomID id.RoomID, plain, html string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: plain,
|
||||
Format: event.FormatHTML,
|
||||
FormattedBody: html,
|
||||
}
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendReact sends a reaction emoji to an event.
|
||||
func (b *Base) SendReact(roomID id.RoomID, eventID id.EventID, emoji string) error {
|
||||
content := &event.ReactionEventContent{
|
||||
RelatesTo: event.RelatesTo{
|
||||
Type: event.RelAnnotation,
|
||||
EventID: eventID,
|
||||
Key: emoji,
|
||||
},
|
||||
}
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventReaction, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendDM sends a direct message to a user. Creates a DM room if needed.
|
||||
func (b *Base) SendDM(userID id.UserID, text string) error {
|
||||
resp, err := b.Client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{
|
||||
Preset: "trusted_private_chat",
|
||||
Invite: []id.UserID{userID},
|
||||
IsDirect: true,
|
||||
InitialState: []*event.Event{
|
||||
{
|
||||
Type: event.StateEncryption,
|
||||
Content: event.Content{
|
||||
Parsed: &event.EncryptionEventContent{
|
||||
Algorithm: id.AlgorithmMegolmV1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create DM room: %w", err)
|
||||
}
|
||||
return b.SendMessage(resp.RoomID, text)
|
||||
}
|
||||
|
||||
// UploadContent uploads data to the Matrix content repository and returns the MXC URI.
|
||||
func (b *Base) UploadContent(data []byte, contentType, filename string) (id.ContentURI, error) {
|
||||
resp, err := b.Client.UploadBytesWithName(context.Background(), data, contentType, filename)
|
||||
if err != nil {
|
||||
return id.ContentURI{}, err
|
||||
}
|
||||
return resp.ContentURI, nil
|
||||
}
|
||||
236
internal/plugin/presence.go
Normal file
236
internal/plugin/presence.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// PresencePlugin handles away status and user profile lookups.
|
||||
type PresencePlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewPresencePlugin creates a new PresencePlugin.
|
||||
func NewPresencePlugin(client *mautrix.Client) *PresencePlugin {
|
||||
return &PresencePlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PresencePlugin) Name() string { return "presence" }
|
||||
|
||||
func (p *PresencePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "away", Description: "Set away status", Usage: "!away [message]", Category: "Personal"},
|
||||
{Name: "afk", Description: "Set away status (alias)", Usage: "!afk [message]", Category: "Personal"},
|
||||
{Name: "back", Description: "Clear away status", Usage: "!back", Category: "Personal"},
|
||||
{Name: "whois", Description: "Show user profile card", Usage: "!whois @user", Category: "Personal"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PresencePlugin) Init() error { return nil }
|
||||
|
||||
func (p *PresencePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *PresencePlugin) OnMessage(ctx MessageContext) error {
|
||||
switch {
|
||||
case p.IsCommand(ctx.Body, "away"):
|
||||
return p.handleAway(ctx, p.GetArgs(ctx.Body, "away"))
|
||||
case p.IsCommand(ctx.Body, "afk"):
|
||||
return p.handleAway(ctx, p.GetArgs(ctx.Body, "afk"))
|
||||
case p.IsCommand(ctx.Body, "back"):
|
||||
return p.handleBack(ctx)
|
||||
case p.IsCommand(ctx.Body, "whois"):
|
||||
return p.handleWhois(ctx)
|
||||
default:
|
||||
// Auto-clear away status on non-command messages
|
||||
return p.autoClearAway(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PresencePlugin) handleAway(ctx MessageContext, message string) error {
|
||||
if message == "" {
|
||||
message = "Away"
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO presence (user_id, status, message, updated_at)
|
||||
VALUES (?, 'away', ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
status = 'away',
|
||||
message = ?,
|
||||
updated_at = ?`,
|
||||
string(ctx.Sender), message, now, message, now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("presence: set away", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to set away status.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("💤 %s is now away: %s", ctx.Sender, message))
|
||||
}
|
||||
|
||||
func (p *PresencePlugin) handleBack(ctx MessageContext) error {
|
||||
result, err := db.Get().Exec(
|
||||
`UPDATE presence SET status = 'online', message = '', updated_at = ?
|
||||
WHERE user_id = ? AND status = 'away'`,
|
||||
time.Now().Unix(), string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("presence: set back", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to clear away status.")
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return p.SendMessage(ctx.RoomID, "You weren't marked as away.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("👋 Welcome back, %s!", ctx.Sender))
|
||||
}
|
||||
|
||||
func (p *PresencePlugin) autoClearAway(ctx MessageContext) error {
|
||||
// Check if user is away
|
||||
var status string
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT status FROM presence WHERE user_id = ?`,
|
||||
string(ctx.Sender),
|
||||
).Scan(&status)
|
||||
|
||||
if err != nil || status != "away" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear away status
|
||||
_, err = db.Get().Exec(
|
||||
`UPDATE presence SET status = 'online', message = '', updated_at = ?
|
||||
WHERE user_id = ?`,
|
||||
time.Now().Unix(), string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("presence: auto-clear away", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("👋 Welcome back, %s! (auto-cleared away status)", ctx.Sender))
|
||||
}
|
||||
|
||||
func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "whois"))
|
||||
if args == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !whois @user:server")
|
||||
}
|
||||
|
||||
targetUser := id.UserID(args)
|
||||
|
||||
// Gather profile data
|
||||
var displayName string
|
||||
var xp, level int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT display_name, xp, level FROM users WHERE user_id = ?`,
|
||||
string(targetUser),
|
||||
).Scan(&displayName, &xp, &level)
|
||||
if err != nil {
|
||||
displayName = string(targetUser)
|
||||
}
|
||||
|
||||
// Get stats
|
||||
var totalMessages, totalWords int
|
||||
_ = db.Get().QueryRow(
|
||||
`SELECT total_messages, total_words FROM user_stats WHERE user_id = ?`,
|
||||
string(targetUser),
|
||||
).Scan(&totalMessages, &totalWords)
|
||||
|
||||
// Get streak
|
||||
var currentStreak int
|
||||
today := time.Now().Format("2006-01-02")
|
||||
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
var countToday, countYesterday int
|
||||
_ = db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM daily_activity WHERE user_id = ? AND date = ?`,
|
||||
string(targetUser), today,
|
||||
).Scan(&countToday)
|
||||
_ = db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM daily_activity WHERE user_id = ? AND date = ?`,
|
||||
string(targetUser), yesterday,
|
||||
).Scan(&countYesterday)
|
||||
if countToday > 0 || countYesterday > 0 {
|
||||
// Count consecutive days backward
|
||||
currentStreak = 0
|
||||
checkDate := time.Now()
|
||||
for {
|
||||
dateStr := checkDate.Format("2006-01-02")
|
||||
var cnt int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM daily_activity WHERE user_id = ? AND date = ?`,
|
||||
string(targetUser), dateStr,
|
||||
).Scan(&cnt)
|
||||
if err != nil || cnt == 0 {
|
||||
break
|
||||
}
|
||||
currentStreak++
|
||||
checkDate = checkDate.AddDate(0, 0, -1)
|
||||
}
|
||||
}
|
||||
|
||||
// Get reputation (from XP log with reason = 'rep')
|
||||
var repCount int
|
||||
_ = db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM xp_log WHERE user_id = ? AND reason = 'rep'`,
|
||||
string(targetUser),
|
||||
).Scan(&repCount)
|
||||
|
||||
// Get presence status
|
||||
var status, statusMsg string
|
||||
_ = db.Get().QueryRow(
|
||||
`SELECT status, message FROM presence WHERE user_id = ?`,
|
||||
string(targetUser),
|
||||
).Scan(&status, &statusMsg)
|
||||
|
||||
// Get birthday
|
||||
var bdayMonth, bdayDay int
|
||||
var timezone string
|
||||
hasBirthday := false
|
||||
err = db.Get().QueryRow(
|
||||
`SELECT month, day, timezone FROM birthdays WHERE user_id = ?`,
|
||||
string(targetUser),
|
||||
).Scan(&bdayMonth, &bdayDay, &timezone)
|
||||
if err == nil {
|
||||
hasBirthday = true
|
||||
}
|
||||
if timezone == "" {
|
||||
timezone = "Not set"
|
||||
}
|
||||
|
||||
// Build profile card
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("👤 **Profile: %s**\n", displayName))
|
||||
sb.WriteString(fmt.Sprintf(" ID: %s\n", targetUser))
|
||||
sb.WriteString(fmt.Sprintf(" Level: %d (XP: %d)\n", level, xp))
|
||||
sb.WriteString(fmt.Sprintf(" Messages: %d | Words: %d\n", totalMessages, totalWords))
|
||||
sb.WriteString(fmt.Sprintf(" Rep: +%d\n", repCount))
|
||||
sb.WriteString(fmt.Sprintf(" Streak: %d days\n", currentStreak))
|
||||
sb.WriteString(fmt.Sprintf(" Timezone: %s\n", timezone))
|
||||
|
||||
if hasBirthday {
|
||||
sb.WriteString(fmt.Sprintf(" Birthday: %s %d\n", time.Month(bdayMonth).String(), bdayDay))
|
||||
}
|
||||
|
||||
if status == "away" {
|
||||
sb.WriteString(fmt.Sprintf(" Status: 💤 Away — %s\n", statusMsg))
|
||||
} else {
|
||||
sb.WriteString(" Status: 🟢 Online\n")
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
95
internal/plugin/ratelimits.go
Normal file
95
internal/plugin/ratelimits.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// RateLimitsPlugin provides rate-limiting utilities for other plugins.
|
||||
type RateLimitsPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewRateLimitsPlugin creates a new rate limits plugin.
|
||||
func NewRateLimitsPlugin(client *mautrix.Client) *RateLimitsPlugin {
|
||||
return &RateLimitsPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *RateLimitsPlugin) Name() string { return "ratelimits" }
|
||||
|
||||
func (p *RateLimitsPlugin) Commands() []CommandDef { return nil }
|
||||
|
||||
func (p *RateLimitsPlugin) Init() error { return nil }
|
||||
|
||||
func (p *RateLimitsPlugin) OnMessage(_ MessageContext) error { return nil }
|
||||
|
||||
func (p *RateLimitsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
// CheckLimit returns true if the user is under the rate limit for the given action.
|
||||
// Admin users always bypass rate limits.
|
||||
func (p *RateLimitsPlugin) CheckLimit(userID id.UserID, action string, maxPerDay int) bool {
|
||||
if p.IsAdmin(userID) {
|
||||
return true
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
// Atomic check-and-increment: only increment if under limit
|
||||
// This avoids the TOCTOU race between SELECT and UPDATE
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO rate_limits (user_id, action, date, count) VALUES (?, ?, ?, 1)
|
||||
ON CONFLICT(user_id, action, date) DO UPDATE SET count = count + 1
|
||||
WHERE count < ?`,
|
||||
string(userID), action, today, maxPerDay,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("ratelimits: increment", "err", err)
|
||||
return true // Fail open on error
|
||||
}
|
||||
|
||||
// Check current count to determine if we're over limit
|
||||
var count int
|
||||
err = d.QueryRow(
|
||||
`SELECT count FROM rate_limits WHERE user_id = ? AND action = ? AND date = ?`,
|
||||
string(userID), action, today,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return true // Fail open
|
||||
}
|
||||
|
||||
return count <= maxPerDay
|
||||
}
|
||||
|
||||
// Remaining returns how many uses remain for the given action today.
|
||||
// Admin users get maxPerDay (effectively unlimited).
|
||||
func (p *RateLimitsPlugin) Remaining(userID id.UserID, action string, maxPerDay int) int {
|
||||
if p.IsAdmin(userID) {
|
||||
return maxPerDay
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT count FROM rate_limits WHERE user_id = ? AND action = ? AND date = ?`,
|
||||
string(userID), action, today,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
count = 0
|
||||
}
|
||||
|
||||
remaining := maxPerDay - count
|
||||
if remaining < 0 {
|
||||
return 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
159
internal/plugin/reactions.go
Normal file
159
internal/plugin/reactions.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ReactionsPlugin logs reactions and provides emoji usage statistics.
|
||||
type ReactionsPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewReactionsPlugin creates a new reactions plugin.
|
||||
func NewReactionsPlugin(client *mautrix.Client) *ReactionsPlugin {
|
||||
return &ReactionsPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReactionsPlugin) Name() string { return "reactions" }
|
||||
|
||||
func (p *ReactionsPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "emojiboard", Description: "Top emoji givers, receivers, and most used emojis", Usage: "!emojiboard", Category: "Reactions"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReactionsPlugin) Init() error { return nil }
|
||||
|
||||
func (p *ReactionsPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "emojiboard") {
|
||||
return p.handleEmojiboard(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ReactionsPlugin) OnReaction(ctx ReactionContext) error {
|
||||
// Resolve the target event's sender via Matrix API
|
||||
targetUser, err := p.resolveEventSender(ctx.RoomID, ctx.TargetEvent)
|
||||
if err != nil {
|
||||
slog.Error("reactions: resolve target event sender", "err", err, "event", ctx.TargetEvent)
|
||||
return nil // Don't fail the whole handler
|
||||
}
|
||||
|
||||
// Don't log self-reactions
|
||||
if ctx.Sender == targetUser {
|
||||
return nil
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO reaction_log (room_id, event_id, sender, target_user, emoji) VALUES (?, ?, ?, ?, ?)`,
|
||||
string(ctx.RoomID), string(ctx.TargetEvent), string(ctx.Sender), string(targetUser), ctx.Emoji,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: log reaction", "err", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveEventSender fetches an event from the Matrix API to determine its sender.
|
||||
func (p *ReactionsPlugin) resolveEventSender(roomID id.RoomID, eventID id.EventID) (id.UserID, error) {
|
||||
evt, err := p.Client.GetEvent(context.Background(), roomID, eventID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get event %s: %w", eventID, err)
|
||||
}
|
||||
return evt.Sender, nil
|
||||
}
|
||||
|
||||
func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
var sb strings.Builder
|
||||
|
||||
// Top 10 emoji givers
|
||||
sb.WriteString("--- Top 10 Emoji Givers ---\n\n")
|
||||
rows, err := d.Query(
|
||||
`SELECT sender, COUNT(*) as cnt FROM reaction_log GROUP BY sender ORDER BY cnt DESC LIMIT 10`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: givers query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
giverCount := appendUserBoard(&sb, rows)
|
||||
rows.Close()
|
||||
|
||||
// Top 10 emoji receivers
|
||||
sb.WriteString("\n--- Top 10 Emoji Receivers ---\n\n")
|
||||
rows, err = d.Query(
|
||||
`SELECT target_user, COUNT(*) as cnt FROM reaction_log GROUP BY target_user ORDER BY cnt DESC LIMIT 10`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: receivers query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
receiverCount := appendUserBoard(&sb, rows)
|
||||
rows.Close()
|
||||
|
||||
// Top 10 most used emojis
|
||||
sb.WriteString("\n--- Top 10 Most Used Emojis ---\n\n")
|
||||
rows, err = d.Query(
|
||||
`SELECT emoji, COUNT(*) as cnt FROM reaction_log GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: emoji query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
emojiCount := appendEmojiBoard(&sb, rows)
|
||||
rows.Close()
|
||||
|
||||
if giverCount == 0 && receiverCount == 0 && emojiCount == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No reaction data yet.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// appendUserBoard writes ranked user lines from query rows.
|
||||
func appendUserBoard(sb *strings.Builder, rows *sql.Rows) int {
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var cnt int
|
||||
if err := rows.Scan(&name, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s — %s reactions\n", prefix, name, formatNumber(cnt)))
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// appendEmojiBoard writes ranked emoji lines.
|
||||
func appendEmojiBoard(sb *strings.Builder, rows *sql.Rows) int {
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var emoji string
|
||||
var cnt int
|
||||
if err := rows.Scan(&emoji, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %s times\n", i+1, emoji, formatNumber(cnt)))
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
225
internal/plugin/reminders.go
Normal file
225
internal/plugin/reminders.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/olebedev/when"
|
||||
"github.com/olebedev/when/rules/common"
|
||||
"github.com/olebedev/when/rules/en"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// RemindersPlugin handles user reminders with natural language time parsing.
|
||||
type RemindersPlugin struct {
|
||||
Base
|
||||
parser *when.Parser
|
||||
}
|
||||
|
||||
// NewRemindersPlugin creates a new RemindersPlugin.
|
||||
func NewRemindersPlugin(client *mautrix.Client) *RemindersPlugin {
|
||||
w := when.New(nil)
|
||||
w.Add(en.All...)
|
||||
w.Add(common.All...)
|
||||
return &RemindersPlugin{
|
||||
Base: NewBase(client),
|
||||
parser: w,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *RemindersPlugin) Name() string { return "reminders" }
|
||||
|
||||
func (p *RemindersPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "remindme", Description: "Set a reminder", Usage: "!remindme <time expression> <message>", Category: "Personal"},
|
||||
{Name: "reminders", Description: "List your pending reminders", Usage: "!reminders", Category: "Personal"},
|
||||
{Name: "unremind", Description: "Cancel a reminder", Usage: "!unremind <id>", Category: "Personal"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *RemindersPlugin) Init() error { return nil }
|
||||
|
||||
func (p *RemindersPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *RemindersPlugin) OnMessage(ctx MessageContext) error {
|
||||
switch {
|
||||
case p.IsCommand(ctx.Body, "remindme"):
|
||||
return p.handleRemindMe(ctx)
|
||||
case p.IsCommand(ctx.Body, "reminders"):
|
||||
return p.handleListReminders(ctx)
|
||||
case p.IsCommand(ctx.Body, "unremind"):
|
||||
return p.handleUnremind(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *RemindersPlugin) handleRemindMe(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "remindme")
|
||||
if args == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !remindme <time expression> <message>\nExamples: !remindme in 30 minutes check the oven, !remindme tomorrow at 9am meeting")
|
||||
}
|
||||
|
||||
result, err := p.parser.Parse(args, time.Now())
|
||||
if err != nil || result == nil {
|
||||
return p.SendMessage(ctx.RoomID, "I couldn't understand that time expression. Try something like: in 30 minutes, tomorrow at 9am, in 2 hours")
|
||||
}
|
||||
|
||||
fireAt := result.Time
|
||||
if fireAt.Before(time.Now()) {
|
||||
return p.SendMessage(ctx.RoomID, "That time is in the past! Please specify a future time.")
|
||||
}
|
||||
|
||||
// Extract message: everything not part of the time expression
|
||||
message := strings.TrimSpace(args[:result.Index] + args[result.Index+len(result.Text):])
|
||||
if message == "" {
|
||||
message = "Reminder!"
|
||||
}
|
||||
|
||||
reminderID := uuid.New().String()[:8]
|
||||
|
||||
_, err = db.Get().Exec(
|
||||
`INSERT INTO reminders (id, user_id, room_id, message, fire_at, fired)
|
||||
VALUES (?, ?, ?, ?, ?, 0)`,
|
||||
reminderID, string(ctx.Sender), string(ctx.RoomID), message, fireAt.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reminders: insert", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to save reminder.")
|
||||
}
|
||||
|
||||
durStr := formatDuration(time.Until(fireAt))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("⏰ Reminder set! I'll remind you %s (ID: %s)\n\"%s\"", durStr, reminderID, message))
|
||||
}
|
||||
|
||||
func (p *RemindersPlugin) handleListReminders(ctx MessageContext) error {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT id, message, fire_at
|
||||
FROM reminders
|
||||
WHERE user_id = ? AND fired = 0
|
||||
ORDER BY fire_at ASC
|
||||
LIMIT 20`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reminders: query", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to fetch reminders.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📋 Your pending reminders:\n")
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var reminderID, message string
|
||||
var fireAt int64
|
||||
if err := rows.Scan(&reminderID, &message, &fireAt); err != nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
t := time.Unix(fireAt, 0)
|
||||
sb.WriteString(fmt.Sprintf(" • [%s] %s — %s\n", reminderID, message, t.Format("Jan 2 15:04 MST")))
|
||||
}
|
||||
|
||||
if !found {
|
||||
return p.SendMessage(ctx.RoomID, "You have no pending reminders.")
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
|
||||
func (p *RemindersPlugin) handleUnremind(ctx MessageContext) error {
|
||||
reminderID := strings.TrimSpace(p.GetArgs(ctx.Body, "unremind"))
|
||||
if reminderID == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !unremind <id>")
|
||||
}
|
||||
|
||||
result, err := db.Get().Exec(
|
||||
`DELETE FROM reminders WHERE id = ? AND user_id = ? AND fired = 0`,
|
||||
reminderID, string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reminders: delete", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to cancel reminder.")
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return p.SendMessage(ctx.RoomID, "Reminder not found or already fired.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("🗑️ Reminder %s cancelled.", reminderID))
|
||||
}
|
||||
|
||||
// FirePendingReminders checks for due reminders and sends them. Called by the scheduler.
|
||||
func FirePendingReminders(client *mautrix.Client) {
|
||||
now := time.Now().Unix()
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT id, user_id, room_id, message
|
||||
FROM reminders
|
||||
WHERE fired = 0 AND fire_at <= ?`,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reminders: query pending", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
base := NewBase(client)
|
||||
|
||||
for rows.Next() {
|
||||
var reminderID, userID, roomID, message string
|
||||
if err := rows.Scan(&reminderID, &userID, &roomID, &message); err != nil {
|
||||
slog.Error("reminders: scan row", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("⏰ Reminder for %s: %s", userID, message)
|
||||
if err := base.SendMessage(id.RoomID(roomID), msg); err != nil {
|
||||
slog.Error("reminders: send reminder", "err", err, "id", reminderID)
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := db.Get().Exec(`UPDATE reminders SET fired = 1 WHERE id = ?`, reminderID)
|
||||
if err != nil {
|
||||
slog.Error("reminders: mark fired", "err", err, "id", reminderID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatDuration returns a human-readable duration string.
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("in %d seconds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
mins := int(d.Minutes())
|
||||
if mins == 1 {
|
||||
return "in 1 minute"
|
||||
}
|
||||
return fmt.Sprintf("in %d minutes", mins)
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
hours := int(d.Hours())
|
||||
mins := int(d.Minutes()) % 60
|
||||
if mins == 0 {
|
||||
if hours == 1 {
|
||||
return "in 1 hour"
|
||||
}
|
||||
return fmt.Sprintf("in %d hours", hours)
|
||||
}
|
||||
return fmt.Sprintf("in %d hours and %d minutes", hours, mins)
|
||||
}
|
||||
days := int(d.Hours()) / 24
|
||||
if days == 1 {
|
||||
return "in 1 day"
|
||||
}
|
||||
return fmt.Sprintf("in %d days", days)
|
||||
}
|
||||
190
internal/plugin/reputation.go
Normal file
190
internal/plugin/reputation.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
var thankRe = regexp.MustCompile(`(?i)\b(thanks|thank\s+you|thankyou|thx|ty|tysm|tyvm)\b`)
|
||||
|
||||
// userMentionRe matches Matrix user IDs like @user:server.tld
|
||||
var userMentionRe = regexp.MustCompile(`@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+`)
|
||||
|
||||
// ReputationPlugin tracks gratitude and awards reputation XP.
|
||||
type ReputationPlugin struct {
|
||||
Base
|
||||
xp *XPPlugin
|
||||
}
|
||||
|
||||
// NewReputationPlugin creates a new reputation plugin.
|
||||
func NewReputationPlugin(client *mautrix.Client, xp *XPPlugin) *ReputationPlugin {
|
||||
return &ReputationPlugin{
|
||||
Base: NewBase(client),
|
||||
xp: xp,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) Name() string { return "reputation" }
|
||||
|
||||
func (p *ReputationPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "rep", Description: "Show reputation count for a user", Usage: "!rep [@user]", Category: "Leveling & Stats"},
|
||||
{Name: "repboard", Description: "Show top 10 reputation receivers", Usage: "!repboard", Category: "Leveling & Stats"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) Init() error { return nil }
|
||||
|
||||
func (p *ReputationPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *ReputationPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "rep") {
|
||||
return p.handleRep(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "repboard") {
|
||||
return p.handleRepboard(ctx)
|
||||
}
|
||||
|
||||
// Passive: detect thank-you messages
|
||||
if thankRe.MatchString(ctx.Body) {
|
||||
return p.handleThank(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) handleThank(ctx MessageContext) error {
|
||||
// Find mentioned users
|
||||
mentions := userMentionRe.FindAllString(ctx.Body, -1)
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
now := time.Now().UTC().Unix()
|
||||
cooldownDuration := int64(24 * 60 * 60) // 24 hours
|
||||
|
||||
for _, mention := range mentions {
|
||||
receiver := id.UserID(mention)
|
||||
|
||||
// Can't thank yourself
|
||||
if receiver == ctx.Sender {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
var lastGiven int64
|
||||
err := d.QueryRow(
|
||||
`SELECT last_given FROM rep_cooldowns WHERE giver = ? AND receiver = ?`,
|
||||
string(ctx.Sender), string(receiver),
|
||||
).Scan(&lastGiven)
|
||||
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
slog.Error("rep: cooldown check", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err == nil && now-lastGiven < cooldownDuration {
|
||||
continue // Still on cooldown
|
||||
}
|
||||
|
||||
// Update cooldown
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO rep_cooldowns (giver, receiver, last_given) VALUES (?, ?, ?)
|
||||
ON CONFLICT(giver, receiver) DO UPDATE SET last_given = ?`,
|
||||
string(ctx.Sender), string(receiver), now, now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("rep: update cooldown", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Award 5 XP via the XP plugin
|
||||
if p.xp != nil {
|
||||
p.xp.GrantXP(receiver, 5, "reputation")
|
||||
}
|
||||
|
||||
if err := p.SendReact(ctx.RoomID, ctx.EventID, "💜"); err != nil {
|
||||
slog.Error("rep: send react", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) handleRep(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "rep")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
|
||||
string(target),
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
slog.Error("rep: query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up reputation.")
|
||||
}
|
||||
|
||||
// Count is total XP from rep, each rep gives 5 XP, so rep count = count / 5
|
||||
repCount := count / 5
|
||||
msg := fmt.Sprintf("💜 %s has received %s reputation points (%s XP from gratitude).",
|
||||
string(target), formatNumber(repCount), formatNumber(count))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, SUM(amount) as total
|
||||
FROM xp_log WHERE reason = 'reputation'
|
||||
GROUP BY user_id ORDER BY total DESC LIMIT 10`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("rep: repboard query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load reputation board.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("💜 Reputation Board — Top 10\n\n")
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var totalXP int
|
||||
if err := rows.Scan(&userID, &totalXP); err != nil {
|
||||
continue
|
||||
}
|
||||
repCount := totalXP / 5
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s — %s rep (%s XP)\n", prefix, userID, formatNumber(repCount), formatNumber(totalXP)))
|
||||
i++
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No reputation data yet.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
324
internal/plugin/retro.go
Normal file
324
internal/plugin/retro.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
|
||||
// rawgGame represents a game from the RAWG API.
|
||||
type rawgGame struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Released string `json:"released"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingTop int `json:"rating_top"`
|
||||
Metacritic int `json:"metacritic"`
|
||||
Playtime int `json:"playtime"`
|
||||
Description string `json:"description_raw"`
|
||||
Platforms []struct {
|
||||
Platform struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"platform"`
|
||||
} `json:"platforms"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
Developers []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"developers"`
|
||||
Publishers []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"publishers"`
|
||||
}
|
||||
|
||||
// rawgSearchResponse is the RAWG search API response.
|
||||
type rawgSearchResponse struct {
|
||||
Count int `json:"count"`
|
||||
Results []rawgGame `json:"results"`
|
||||
}
|
||||
|
||||
// retroCacheEntry holds cached retro search data.
|
||||
type retroCacheEntry struct {
|
||||
Results []rawgGame `json:"results"`
|
||||
Detail *rawgGame `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// RetroPlugin provides retro game lookups via the RAWG API.
|
||||
type RetroPlugin struct {
|
||||
Base
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewRetroPlugin creates a new RetroPlugin.
|
||||
func NewRetroPlugin(client *mautrix.Client) *RetroPlugin {
|
||||
return &RetroPlugin{
|
||||
Base: NewBase(client),
|
||||
apiKey: os.Getenv("RAWG_API_KEY"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *RetroPlugin) Name() string { return "retro" }
|
||||
|
||||
func (p *RetroPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "game", Description: "Search for a game", Usage: "!game <query>", Category: "Entertainment"},
|
||||
{Name: "retro", Description: "Search for a game (alias)", Usage: "!retro <query>", Category: "Entertainment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *RetroPlugin) Init() error { return nil }
|
||||
|
||||
func (p *RetroPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *RetroPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "game") {
|
||||
return p.handleSearch(ctx, p.GetArgs(ctx.Body, "game"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "retro") {
|
||||
return p.handleSearch(ctx, p.GetArgs(ctx.Body, "retro"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *RetroPlugin) handleSearch(ctx MessageContext, query string) error {
|
||||
if query == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !game <query> or !retro <query>")
|
||||
}
|
||||
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Game lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
entry, err := p.fetchGames(query)
|
||||
if err != nil {
|
||||
slog.Error("retro: fetch failed", "query", query, "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for games.")
|
||||
}
|
||||
|
||||
if len(entry.Results) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No games found for \"%s\".", query))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// First result gets detailed info
|
||||
if entry.Detail != nil {
|
||||
sb.WriteString(p.formatGameDetail(entry.Detail))
|
||||
} else {
|
||||
sb.WriteString(p.formatGameBrief(entry.Results[0]))
|
||||
}
|
||||
|
||||
// Additional results (2nd and 3rd) as brief entries
|
||||
limit := 3
|
||||
if len(entry.Results) < limit {
|
||||
limit = len(entry.Results)
|
||||
}
|
||||
if limit > 1 {
|
||||
sb.WriteString("\n\nOther results:\n")
|
||||
for i := 1; i < limit; i++ {
|
||||
sb.WriteString(p.formatGameBrief(entry.Results[i]))
|
||||
if i < limit-1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *RetroPlugin) fetchGames(query string) (*retroCacheEntry, error) {
|
||||
d := db.Get()
|
||||
cacheKey := strings.ToLower(query)
|
||||
|
||||
// Check cache (7-day TTL)
|
||||
var cached string
|
||||
var cachedAt int64
|
||||
err := d.QueryRow(
|
||||
`SELECT data, cached_at FROM retro_cache WHERE search_term = ?`, cacheKey,
|
||||
).Scan(&cached, &cachedAt)
|
||||
if err == nil && time.Now().Unix()-cachedAt < 7*24*3600 {
|
||||
var entry retroCacheEntry
|
||||
if json.Unmarshal([]byte(cached), &entry) == nil {
|
||||
return &entry, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Search API
|
||||
encoded := url.QueryEscape(query)
|
||||
apiURL := fmt.Sprintf("https://api.rawg.io/api/games?key=%s&search=%s&page_size=3",
|
||||
p.apiKey, encoded)
|
||||
|
||||
var searchResp rawgSearchResponse
|
||||
if err := p.rawgGet(apiURL, &searchResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := &retroCacheEntry{
|
||||
Results: searchResp.Results,
|
||||
}
|
||||
|
||||
// Fetch detailed info for the first result
|
||||
if len(searchResp.Results) > 0 {
|
||||
detailURL := fmt.Sprintf("https://api.rawg.io/api/games/%d?key=%s",
|
||||
searchResp.Results[0].ID, p.apiKey)
|
||||
var detail rawgGame
|
||||
if err := p.rawgGet(detailURL, &detail); err != nil {
|
||||
slog.Warn("retro: detail fetch failed, using search result", "err", err)
|
||||
} else {
|
||||
entry.Detail = &detail
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(entry)
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO retro_cache (search_term, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(search_term) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
cacheKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("retro: cache write", "err", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (p *RetroPlugin) rawgGet(apiURL string, target interface{}) error {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("rawg returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
func (p *RetroPlugin) formatGameDetail(g *rawgGame) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s\n", g.Name))
|
||||
|
||||
if g.Released != "" {
|
||||
sb.WriteString(fmt.Sprintf("Released: %s\n", g.Released))
|
||||
}
|
||||
|
||||
if g.Rating > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Rating: %.2f/5\n", g.Rating))
|
||||
}
|
||||
|
||||
if g.Metacritic > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Metacritic: %d\n", g.Metacritic))
|
||||
}
|
||||
|
||||
if g.Playtime > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Avg Playtime: %dh\n", g.Playtime))
|
||||
}
|
||||
|
||||
if len(g.Platforms) > 0 {
|
||||
platforms := make([]string, 0, len(g.Platforms))
|
||||
for _, pl := range g.Platforms {
|
||||
if pl.Platform.Name != "" {
|
||||
platforms = append(platforms, pl.Platform.Name)
|
||||
}
|
||||
}
|
||||
if len(platforms) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Platforms: %s\n", strings.Join(platforms, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
if len(g.Genres) > 0 {
|
||||
genres := make([]string, len(g.Genres))
|
||||
for i, gen := range g.Genres {
|
||||
genres[i] = gen.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||
}
|
||||
|
||||
if len(g.Developers) > 0 {
|
||||
devs := make([]string, len(g.Developers))
|
||||
for i, d := range g.Developers {
|
||||
devs[i] = d.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Developers: %s\n", strings.Join(devs, ", ")))
|
||||
}
|
||||
|
||||
if len(g.Publishers) > 0 {
|
||||
pubs := make([]string, len(g.Publishers))
|
||||
for i, pub := range g.Publishers {
|
||||
pubs[i] = pub.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Publishers: %s\n", strings.Join(pubs, ", ")))
|
||||
}
|
||||
|
||||
if g.Description != "" {
|
||||
desc := g.Description
|
||||
if len(desc) > 400 {
|
||||
desc = desc[:400] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n%s\n", desc))
|
||||
}
|
||||
|
||||
if g.Slug != "" {
|
||||
sb.WriteString(fmt.Sprintf("\nhttps://rawg.io/games/%s", g.Slug))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *RetroPlugin) formatGameBrief(g rawgGame) string {
|
||||
parts := []string{g.Name}
|
||||
|
||||
if g.Released != "" {
|
||||
parts = append(parts, fmt.Sprintf("(%s)", g.Released))
|
||||
}
|
||||
|
||||
if g.Rating > 0 {
|
||||
parts = append(parts, fmt.Sprintf("- %.2f/5", g.Rating))
|
||||
}
|
||||
|
||||
if len(g.Platforms) > 0 {
|
||||
platforms := make([]string, 0, len(g.Platforms))
|
||||
for _, pl := range g.Platforms {
|
||||
if pl.Platform.Name != "" {
|
||||
platforms = append(platforms, pl.Platform.Name)
|
||||
}
|
||||
}
|
||||
if len(platforms) > 0 {
|
||||
// Show up to 3 platforms to keep it brief
|
||||
if len(platforms) > 3 {
|
||||
platforms = append(platforms[:3], "...")
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s]", strings.Join(platforms, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
return " " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// Compile-time interface compliance checks.
|
||||
var _ Plugin = (*RetroPlugin)(nil)
|
||||
48
internal/plugin/shade.go
Normal file
48
internal/plugin/shade.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
|
||||
// ShadePlugin is a stub plugin for future shade/roast features.
|
||||
type ShadePlugin struct {
|
||||
Base
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewShadePlugin creates a new shade plugin.
|
||||
func NewShadePlugin(client *mautrix.Client) *ShadePlugin {
|
||||
return &ShadePlugin{
|
||||
Base: NewBase(client),
|
||||
enabled: os.Getenv("FEATURE_SHADE") == "true" || os.Getenv("FEATURE_SHADE") == "1",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ShadePlugin) Name() string { return "shade" }
|
||||
|
||||
func (p *ShadePlugin) Commands() []CommandDef {
|
||||
if !p.enabled {
|
||||
return nil
|
||||
}
|
||||
return []CommandDef{
|
||||
{Name: "shade", Description: "Throw some shade (coming soon)", Usage: "!shade", Category: "LLM & Sentiment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ShadePlugin) Init() error { return nil }
|
||||
|
||||
func (p *ShadePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *ShadePlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.IsCommand(ctx.Body, "shade") {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Coming soon.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
314
internal/plugin/stats.go
Normal file
314
internal/plugin/stats.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/util"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
var milestoneThresholds = []int{
|
||||
1_000, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000,
|
||||
}
|
||||
|
||||
// StatsPlugin passively tracks message statistics and provides query commands.
|
||||
type StatsPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewStatsPlugin creates a new stats plugin.
|
||||
func NewStatsPlugin(client *mautrix.Client) *StatsPlugin {
|
||||
return &StatsPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StatsPlugin) Name() string { return "stats" }
|
||||
|
||||
func (p *StatsPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "stats", Description: "Show message statistics for a user", Usage: "!stats [@user]", Category: "Leveling & Stats"},
|
||||
{Name: "rankings", Description: "Show rankings for a stat category", Usage: "!rankings [words|links|questions|emojis]", Category: "Leveling & Stats"},
|
||||
{Name: "personality", Description: "Show your chat personality archetype", Usage: "!personality", Category: "Leveling & Stats"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StatsPlugin) Init() error { return nil }
|
||||
|
||||
func (p *StatsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *StatsPlugin) OnMessage(ctx MessageContext) error {
|
||||
// Handle commands first
|
||||
if p.IsCommand(ctx.Body, "stats") {
|
||||
return p.handleStats(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "rankings") {
|
||||
return p.handleRankings(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "personality") {
|
||||
return p.handlePersonality(ctx)
|
||||
}
|
||||
|
||||
// Skip tracking for bot commands
|
||||
if ctx.IsCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Passive: track message stats
|
||||
return p.trackMessage(ctx)
|
||||
}
|
||||
|
||||
func (p *StatsPlugin) trackMessage(ctx MessageContext) error {
|
||||
stats := util.ParseMessage(ctx.Body)
|
||||
d := db.Get()
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Determine time-of-day buckets
|
||||
hour := now.Hour()
|
||||
nightIncr := 0
|
||||
morningIncr := 0
|
||||
if hour >= 0 && hour < 6 {
|
||||
nightIncr = 1
|
||||
} else if hour >= 6 && hour < 12 {
|
||||
morningIncr = 1
|
||||
}
|
||||
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO user_stats (user_id, total_messages, total_words, total_chars, total_links,
|
||||
total_images, total_questions, total_exclamations, total_emojis, night_messages, morning_messages, updated_at)
|
||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
total_messages = total_messages + 1,
|
||||
total_words = total_words + ?,
|
||||
total_chars = total_chars + ?,
|
||||
total_links = total_links + ?,
|
||||
total_images = total_images + ?,
|
||||
total_questions = total_questions + ?,
|
||||
total_exclamations = total_exclamations + ?,
|
||||
total_emojis = total_emojis + ?,
|
||||
night_messages = night_messages + ?,
|
||||
morning_messages = morning_messages + ?,
|
||||
updated_at = ?`,
|
||||
string(ctx.Sender),
|
||||
stats.Words, stats.Chars, stats.Links, stats.Images,
|
||||
stats.Questions, stats.Exclamations, stats.Emojis,
|
||||
nightIncr, morningIncr, now.Unix(),
|
||||
// ON CONFLICT values
|
||||
stats.Words, stats.Chars, stats.Links, stats.Images,
|
||||
stats.Questions, stats.Exclamations, stats.Emojis,
|
||||
nightIncr, morningIncr, now.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("stats: track message", "err", err)
|
||||
return nil // Don't fail the message pipeline
|
||||
}
|
||||
|
||||
// Room milestone tracking
|
||||
p.checkRoomMilestone(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StatsPlugin) checkRoomMilestone(ctx MessageContext) {
|
||||
d := db.Get()
|
||||
|
||||
// Upsert room message count
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO room_milestones (room_id, total_messages, last_milestone)
|
||||
VALUES (?, 1, 0)
|
||||
ON CONFLICT(room_id) DO UPDATE SET total_messages = total_messages + 1`,
|
||||
string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("stats: room milestone upsert", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
var totalMessages, lastMilestone int
|
||||
err = d.QueryRow(
|
||||
`SELECT total_messages, last_milestone FROM room_milestones WHERE room_id = ?`,
|
||||
string(ctx.RoomID),
|
||||
).Scan(&totalMessages, &lastMilestone)
|
||||
if err != nil {
|
||||
slog.Error("stats: room milestone read", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we crossed a new milestone
|
||||
for _, threshold := range milestoneThresholds {
|
||||
if totalMessages >= threshold && lastMilestone < threshold {
|
||||
_, err = d.Exec(
|
||||
`UPDATE room_milestones SET last_milestone = ? WHERE room_id = ?`,
|
||||
threshold, string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("stats: update milestone", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("🎉 This room just hit %s messages! Keep the conversation going!", formatNumber(threshold))
|
||||
if err := p.SendMessage(ctx.RoomID, msg); err != nil {
|
||||
slog.Error("stats: milestone announcement", "err", err)
|
||||
}
|
||||
return // Only announce one milestone at a time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StatsPlugin) handleStats(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "stats")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var totalMsg, totalWords, totalChars, totalLinks, totalImages int
|
||||
var totalQuestions, totalExclamations, totalEmojis, nightMsg, morningMsg int
|
||||
|
||||
err := d.QueryRow(
|
||||
`SELECT total_messages, total_words, total_chars, total_links, total_images,
|
||||
total_questions, total_exclamations, total_emojis, night_messages, morning_messages
|
||||
FROM user_stats WHERE user_id = ?`, string(target),
|
||||
).Scan(&totalMsg, &totalWords, &totalChars, &totalLinks, &totalImages,
|
||||
&totalQuestions, &totalExclamations, &totalEmojis, &nightMsg, &morningMsg)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No stats found for %s.", string(target)))
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("stats: query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up stats.")
|
||||
}
|
||||
|
||||
avgWords := 0
|
||||
if totalMsg > 0 {
|
||||
avgWords = totalWords / totalMsg
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"📊 Stats for %s\n"+
|
||||
"Messages: %s | Words: %s | Chars: %s\n"+
|
||||
"Links: %s | Images: %s | Emojis: %s\n"+
|
||||
"Questions: %s | Exclamations: %s\n"+
|
||||
"Night msgs (00-06): %s | Morning msgs (06-12): %s\n"+
|
||||
"Avg words/msg: %d",
|
||||
string(target),
|
||||
formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalChars),
|
||||
formatNumber(totalLinks), formatNumber(totalImages), formatNumber(totalEmojis),
|
||||
formatNumber(totalQuestions), formatNumber(totalExclamations),
|
||||
formatNumber(nightMsg), formatNumber(morningMsg),
|
||||
avgWords,
|
||||
)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
|
||||
args := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "rankings")))
|
||||
|
||||
column := "total_words"
|
||||
label := "Words"
|
||||
switch args {
|
||||
case "links":
|
||||
column = "total_links"
|
||||
label = "Links"
|
||||
case "questions":
|
||||
column = "total_questions"
|
||||
label = "Questions"
|
||||
case "emojis":
|
||||
column = "total_emojis"
|
||||
label = "Emojis"
|
||||
case "words", "":
|
||||
column = "total_words"
|
||||
label = "Words"
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Valid categories: words, links, questions, emojis")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
// Using fmt.Sprintf for column name is safe here since we control the value above
|
||||
query := fmt.Sprintf(
|
||||
`SELECT user_id, %s FROM user_stats WHERE %s > 0 ORDER BY %s DESC LIMIT 10`,
|
||||
column, column, column,
|
||||
)
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
slog.Error("stats: rankings query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load rankings.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📈 %s Rankings — Top 10\n\n", label))
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var val int
|
||||
if err := rows.Scan(&userID, &val); err != nil {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s — %s %s\n", prefix, userID, formatNumber(val), strings.ToLower(label)))
|
||||
i++
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No %s data yet.", strings.ToLower(label)))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *StatsPlugin) handlePersonality(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
var totalMsg, totalWords, totalChars, totalLinks, totalImages int
|
||||
var totalQuestions, totalExclamations, totalEmojis int
|
||||
|
||||
err := d.QueryRow(
|
||||
`SELECT total_messages, total_words, total_chars, total_links, total_images,
|
||||
total_questions, total_exclamations, total_emojis
|
||||
FROM user_stats WHERE user_id = ?`, string(ctx.Sender),
|
||||
).Scan(&totalMsg, &totalWords, &totalChars, &totalLinks, &totalImages,
|
||||
&totalQuestions, &totalExclamations, &totalEmojis)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Not enough data to determine your personality yet. Keep chatting!")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("stats: personality query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to analyze personality.")
|
||||
}
|
||||
|
||||
msgStats := util.MessageStats{
|
||||
Words: totalWords,
|
||||
Chars: totalChars,
|
||||
Links: totalLinks,
|
||||
Images: totalImages,
|
||||
Questions: totalQuestions,
|
||||
Exclamations: totalExclamations,
|
||||
Emojis: totalEmojis,
|
||||
}
|
||||
|
||||
archetype := util.DeriveArchetype(msgStats, totalMsg)
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"🧠 %s, your chat personality is: **%s**\n_%s_\n\nBased on %s messages analyzed.",
|
||||
string(ctx.Sender), archetype.Name, archetype.Description, formatNumber(totalMsg),
|
||||
)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
344
internal/plugin/stocks.go
Normal file
344
internal/plugin/stocks.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
|
||||
// finnhubQuote is the response from the Finnhub quote endpoint.
|
||||
type finnhubQuote struct {
|
||||
Current float64 `json:"c"` // Current price
|
||||
Change float64 `json:"d"` // Change
|
||||
ChangePct float64 `json:"dp"` // Percent change
|
||||
High float64 `json:"h"` // High price of the day
|
||||
Low float64 `json:"l"` // Low price of the day
|
||||
Open float64 `json:"o"` // Open price of the day
|
||||
PrevClose float64 `json:"pc"` // Previous close price
|
||||
Timestamp int64 `json:"t"` // Timestamp
|
||||
}
|
||||
|
||||
// finnhubProfile is the response from the Finnhub company profile endpoint.
|
||||
type finnhubProfile struct {
|
||||
Name string `json:"name"`
|
||||
Ticker string `json:"ticker"`
|
||||
Exchange string `json:"exchange"`
|
||||
Industry string `json:"finnhubIndustry"`
|
||||
MarketCap float64 `json:"marketCapitalization"` // in millions
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// stockCacheEntry holds cached stock data.
|
||||
type stockCacheEntry struct {
|
||||
Quote finnhubQuote `json:"quote"`
|
||||
Profile finnhubProfile `json:"profile"`
|
||||
}
|
||||
|
||||
// StocksPlugin provides stock market quotes via Finnhub.
|
||||
type StocksPlugin struct {
|
||||
Base
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewStocksPlugin creates a new StocksPlugin.
|
||||
func NewStocksPlugin(client *mautrix.Client) *StocksPlugin {
|
||||
return &StocksPlugin{
|
||||
Base: NewBase(client),
|
||||
apiKey: os.Getenv("FINNHUB_API_KEY"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) Name() string { return "stocks" }
|
||||
|
||||
func (p *StocksPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "stock", Description: "Get stock quote(s)", Usage: "!stock <ticker> [ticker2...]", Category: "Entertainment"},
|
||||
{Name: "stockwatch", Description: "Manage your stock watchlist", Usage: "!stockwatch add|list|remove <ticker>", Category: "Entertainment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) Init() error { return nil }
|
||||
|
||||
func (p *StocksPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *StocksPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "stock") {
|
||||
return p.handleStock(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "stockwatch") {
|
||||
return p.handleStockwatch(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) handleStock(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "stock")
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stock <ticker> [ticker2...]")
|
||||
}
|
||||
|
||||
if p.apiKey == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Stock lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
tickers := strings.Fields(strings.ToUpper(args))
|
||||
if len(tickers) > 5 {
|
||||
tickers = tickers[:5]
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, ticker := range tickers {
|
||||
if i > 0 {
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
entry, err := p.fetchStock(ticker)
|
||||
if err != nil {
|
||||
slog.Error("stocks: fetch failed", "ticker", ticker, "err", err)
|
||||
sb.WriteString(fmt.Sprintf("%s: Failed to fetch data", ticker))
|
||||
continue
|
||||
}
|
||||
sb.WriteString(p.formatStock(ticker, entry))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) fetchStock(ticker string) (*stockCacheEntry, error) {
|
||||
d := db.Get()
|
||||
|
||||
// Check cache (60s TTL)
|
||||
var cached string
|
||||
var cachedAt int64
|
||||
err := d.QueryRow(
|
||||
`SELECT data, cached_at FROM stocks_cache WHERE ticker = ?`, ticker,
|
||||
).Scan(&cached, &cachedAt)
|
||||
if err == nil && time.Now().Unix()-cachedAt < 60 {
|
||||
var entry stockCacheEntry
|
||||
if json.Unmarshal([]byte(cached), &entry) == nil {
|
||||
return &entry, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch quote
|
||||
quote, err := p.fetchFinnhubQuote(ticker)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch quote: %w", err)
|
||||
}
|
||||
|
||||
// Fetch profile
|
||||
profile, err := p.fetchFinnhubProfile(ticker)
|
||||
if err != nil {
|
||||
slog.Warn("stocks: profile fetch failed, continuing without it", "ticker", ticker, "err", err)
|
||||
profile = &finnhubProfile{}
|
||||
}
|
||||
|
||||
entry := &stockCacheEntry{
|
||||
Quote: *quote,
|
||||
Profile: *profile,
|
||||
}
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(entry)
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO stocks_cache (ticker, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(ticker) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
ticker, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("stocks: cache write", "err", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) fetchFinnhubQuote(ticker string) (*finnhubQuote, error) {
|
||||
url := fmt.Sprintf("https://finnhub.io/api/v1/quote?symbol=%s&token=%s", ticker, p.apiKey)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("finnhub quote returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var quote finnhubQuote
|
||||
if err := json.NewDecoder(resp.Body).Decode("e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return "e, nil
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) fetchFinnhubProfile(ticker string) (*finnhubProfile, error) {
|
||||
url := fmt.Sprintf("https://finnhub.io/api/v1/stock/profile2?symbol=%s&token=%s", ticker, p.apiKey)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("finnhub profile returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var profile finnhubProfile
|
||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) formatStock(ticker string, entry *stockCacheEntry) string {
|
||||
q := entry.Quote
|
||||
pr := entry.Profile
|
||||
|
||||
changeSign := ""
|
||||
if q.Change > 0 {
|
||||
changeSign = "+"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
if pr.Name != "" {
|
||||
sb.WriteString(fmt.Sprintf("%s (%s)", pr.Name, ticker))
|
||||
} else {
|
||||
sb.WriteString(ticker)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nPrice: $%.2f", q.Current))
|
||||
sb.WriteString(fmt.Sprintf("\nChange: %s%.2f (%s%.2f%%)", changeSign, q.Change, changeSign, q.ChangePct))
|
||||
sb.WriteString(fmt.Sprintf("\nDay Range: $%.2f - $%.2f", q.Low, q.High))
|
||||
|
||||
if pr.MarketCap > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\nMarket Cap: %s", formatMarketCap(pr.MarketCap)))
|
||||
}
|
||||
if pr.Exchange != "" {
|
||||
sb.WriteString(fmt.Sprintf("\nExchange: %s", pr.Exchange))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatMarketCap(capMillions float64) string {
|
||||
switch {
|
||||
case capMillions >= 1_000_000:
|
||||
return fmt.Sprintf("$%.2fT", capMillions/1_000_000)
|
||||
case capMillions >= 1_000:
|
||||
return fmt.Sprintf("$%.2fB", capMillions/1_000)
|
||||
default:
|
||||
return fmt.Sprintf("$%.2fM", capMillions)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) handleStockwatch(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "stockwatch")
|
||||
parts := strings.Fields(args)
|
||||
if len(parts) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch add|list|remove <ticker>")
|
||||
}
|
||||
|
||||
sub := strings.ToLower(parts[0])
|
||||
switch sub {
|
||||
case "add":
|
||||
if len(parts) < 2 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch add <ticker>")
|
||||
}
|
||||
return p.watchlistAdd(ctx, strings.ToUpper(parts[1]))
|
||||
case "remove":
|
||||
if len(parts) < 2 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch remove <ticker>")
|
||||
}
|
||||
return p.watchlistRemove(ctx, strings.ToUpper(parts[1]))
|
||||
case "list":
|
||||
return p.watchlistList(ctx)
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch add|list|remove <ticker>")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) watchlistAdd(ctx MessageContext, ticker string) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO stock_watchlist (user_id, ticker, room_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, ticker) DO NOTHING`,
|
||||
string(ctx.Sender), ticker, string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("stocks: watchlist add", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Added %s to your watchlist.", ticker))
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) watchlistRemove(ctx MessageContext, ticker string) error {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(
|
||||
`DELETE FROM stock_watchlist WHERE user_id = ? AND ticker = ?`,
|
||||
string(ctx.Sender), ticker,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("stocks: watchlist remove", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is not in your watchlist.", ticker))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed %s from your watchlist.", ticker))
|
||||
}
|
||||
|
||||
func (p *StocksPlugin) watchlistList(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT ticker FROM stock_watchlist WHERE user_id = ? ORDER BY ticker`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("stocks: watchlist list", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tickers []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err != nil {
|
||||
continue
|
||||
}
|
||||
tickers = append(tickers, t)
|
||||
}
|
||||
|
||||
if len(tickers) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Your stock watchlist is empty. Use !stockwatch add <ticker> to add stocks.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Your Stock Watchlist:\n")
|
||||
for _, t := range tickers {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", t))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
256
internal/plugin/streaks.go
Normal file
256
internal/plugin/streaks.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// StreaksPlugin tracks daily activity streaks and first-poster records.
|
||||
type StreaksPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewStreaksPlugin creates a new streaks plugin.
|
||||
func NewStreaksPlugin(client *mautrix.Client) *StreaksPlugin {
|
||||
return &StreaksPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) Name() string { return "streaks" }
|
||||
|
||||
func (p *StreaksPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "streak", Description: "Show your current and longest activity streak", Usage: "!streak", Category: "Leveling & Stats"},
|
||||
{Name: "firstboard", Description: "Show top first-posters of the day", Usage: "!firstboard", Category: "Leveling & Stats"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) Init() error { return nil }
|
||||
|
||||
func (p *StreaksPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *StreaksPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "streak") {
|
||||
return p.handleStreak(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "firstboard") {
|
||||
return p.handleFirstboard(ctx)
|
||||
}
|
||||
|
||||
// Skip tracking for bot commands
|
||||
if ctx.IsCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Passive: track daily activity and first poster
|
||||
p.trackDailyActivity(ctx)
|
||||
p.trackFirstPoster(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) trackDailyActivity(ctx MessageContext) {
|
||||
d := db.Get()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)
|
||||
ON CONFLICT(user_id, date) DO UPDATE SET message_count = message_count + 1`,
|
||||
string(ctx.Sender), today,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("streaks: track daily activity", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) trackFirstPoster(ctx MessageContext) {
|
||||
d := db.Get()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
now := time.Now().UTC().Unix()
|
||||
|
||||
// INSERT OR IGNORE — only the first poster for this room+date wins
|
||||
_, err := d.Exec(
|
||||
`INSERT OR IGNORE INTO daily_first (room_id, date, user_id, timestamp) VALUES (?, ?, ?, ?)`,
|
||||
string(ctx.RoomID), today, string(ctx.Sender), now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("streaks: track first poster", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) handleStreak(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "streak")
|
||||
if args != "" {
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
|
||||
// Get all active dates for this user, sorted descending
|
||||
rows, err := d.Query(
|
||||
`SELECT date FROM daily_activity WHERE user_id = ? ORDER BY date DESC`,
|
||||
string(target),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("streaks: query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up streak.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var dates []string
|
||||
for rows.Next() {
|
||||
var date string
|
||||
if err := rows.Scan(&date); err != nil {
|
||||
continue
|
||||
}
|
||||
dates = append(dates, date)
|
||||
}
|
||||
|
||||
if len(dates) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no activity recorded yet.", string(target)))
|
||||
}
|
||||
|
||||
currentStreak, longestStreak := calculateStreaks(dates)
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"🔥 %s — Streak Report\nCurrent streak: %s days\nLongest streak: %s days\nTotal active days: %s",
|
||||
string(target),
|
||||
formatNumber(currentStreak),
|
||||
formatNumber(longestStreak),
|
||||
formatNumber(len(dates)),
|
||||
)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
// calculateStreaks computes current and longest streaks from descending-sorted date strings.
|
||||
func calculateStreaks(dates []string) (current int, longest int) {
|
||||
if len(dates) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
// Parse first date — current streak only counts if it includes today or yesterday
|
||||
firstDate, err := time.Parse("2006-01-02", dates[0])
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
todayDate, _ := time.Parse("2006-01-02", today)
|
||||
daysSinceFirst := int(todayDate.Sub(firstDate).Hours() / 24)
|
||||
if daysSinceFirst > 1 {
|
||||
// Streak is broken — current streak is 0
|
||||
// Still calculate longest
|
||||
current = 0
|
||||
} else {
|
||||
// Count current streak
|
||||
current = 1
|
||||
for i := 1; i < len(dates); i++ {
|
||||
prev, err1 := time.Parse("2006-01-02", dates[i-1])
|
||||
curr, err2 := time.Parse("2006-01-02", dates[i])
|
||||
if err1 != nil || err2 != nil {
|
||||
break
|
||||
}
|
||||
diff := int(prev.Sub(curr).Hours() / 24)
|
||||
if diff == 1 {
|
||||
current++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate longest streak (walk all dates)
|
||||
streak := 1
|
||||
longest = 1
|
||||
for i := 1; i < len(dates); i++ {
|
||||
prev, err1 := time.Parse("2006-01-02", dates[i-1])
|
||||
curr, err2 := time.Parse("2006-01-02", dates[i])
|
||||
if err1 != nil || err2 != nil {
|
||||
streak = 1
|
||||
continue
|
||||
}
|
||||
diff := int(prev.Sub(curr).Hours() / 24)
|
||||
if diff == 1 {
|
||||
streak++
|
||||
} else {
|
||||
streak = 1
|
||||
}
|
||||
if streak > longest {
|
||||
longest = streak
|
||||
}
|
||||
}
|
||||
|
||||
if current > longest {
|
||||
longest = current
|
||||
}
|
||||
|
||||
return current, longest
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) handleFirstboard(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, COUNT(*) as wins
|
||||
FROM daily_first
|
||||
GROUP BY user_id
|
||||
ORDER BY wins DESC
|
||||
LIMIT 10`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("streaks: firstboard query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load first-poster board.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🌅 First Poster Board — Top 10\n\n")
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var wins int
|
||||
if err := rows.Scan(&userID, &wins); err != nil {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s — %s first posts\n", prefix, userID, formatNumber(wins)))
|
||||
i++
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No first-poster data yet.")
|
||||
}
|
||||
|
||||
// Also show today's first poster
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
var todayFirst sql.NullString
|
||||
_ = d.QueryRow(
|
||||
`SELECT user_id FROM daily_first WHERE room_id = ? AND date = ?`,
|
||||
string(ctx.RoomID), today,
|
||||
).Scan(&todayFirst)
|
||||
|
||||
if todayFirst.Valid {
|
||||
sb.WriteString(fmt.Sprintf("\nToday's first poster in this room: %s", todayFirst.String))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
238
internal/plugin/tools.go
Normal file
238
internal/plugin/tools.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/skip2/go-qrcode"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ToolsPlugin provides calculator and QR code generation.
|
||||
type ToolsPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewToolsPlugin creates a new ToolsPlugin.
|
||||
func NewToolsPlugin(client *mautrix.Client) *ToolsPlugin {
|
||||
return &ToolsPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ToolsPlugin) Name() string { return "tools" }
|
||||
|
||||
func (p *ToolsPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "calc", Description: "Math calculator", Usage: "!calc <expression>", Category: "Lookup & Reference"},
|
||||
{Name: "qr", Description: "Generate a QR code", Usage: "!qr <text>", Category: "Lookup & Reference"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ToolsPlugin) Init() error { return nil }
|
||||
|
||||
func (p *ToolsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *ToolsPlugin) OnMessage(ctx MessageContext) error {
|
||||
switch {
|
||||
case p.IsCommand(ctx.Body, "calc"):
|
||||
return p.handleCalc(ctx)
|
||||
case p.IsCommand(ctx.Body, "qr"):
|
||||
return p.handleQR(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ToolsPlugin) handleCalc(ctx MessageContext) error {
|
||||
expression := strings.TrimSpace(p.GetArgs(ctx.Body, "calc"))
|
||||
if expression == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !calc <expression>\nExamples: !calc 2+2, !calc sqrt(144), !calc 5 times 3")
|
||||
}
|
||||
|
||||
// Normalize natural language
|
||||
normalized := normalizeExpression(expression)
|
||||
|
||||
// Evaluate using expr
|
||||
program, err := expr.Compile(normalized)
|
||||
if err != nil {
|
||||
slog.Debug("calc: compile error", "expr", normalized, "err", err)
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Could not parse expression: %s", expression))
|
||||
}
|
||||
|
||||
output, err := expr.Run(program, nil)
|
||||
if err != nil {
|
||||
slog.Debug("calc: eval error", "expr", normalized, "err", err)
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Error evaluating: %s", expression))
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🧮 %s = **%s**", expression, formatCalcResult(output)))
|
||||
}
|
||||
|
||||
// commaNumberRe matches numbers with commas like 40,000 or 1,234,567.89
|
||||
var commaNumberRe = regexp.MustCompile(`\d{1,3}(,\d{3})+(\.\d+)?`)
|
||||
|
||||
// percentOfRe matches patterns like "8% of 40000" or "15.5% of 200"
|
||||
var percentOfRe = regexp.MustCompile(`(?i)([\d.]+)\s*%\s*of\s+([\d,.]+)`)
|
||||
|
||||
// normalizeExpression converts natural language math to operators.
|
||||
func normalizeExpression(s string) string {
|
||||
// Remove common prefixes
|
||||
s = strings.TrimSpace(s)
|
||||
lower := strings.ToLower(s)
|
||||
for _, prefix := range []string{"what is ", "what's ", "calculate ", "compute ", "evaluate "} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
s = s[len(prefix):]
|
||||
lower = strings.ToLower(s)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Strip commas from numbers (e.g. 40,000 -> 40000)
|
||||
s = commaNumberRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||
return strings.ReplaceAll(m, ",", "")
|
||||
})
|
||||
|
||||
// Handle "X% of Y" -> (X / 100) * Y
|
||||
s = percentOfRe.ReplaceAllString(s, "($1 / 100) * $2")
|
||||
|
||||
// Replace natural language operators (order matters: longer phrases first)
|
||||
replacements := []struct{ from, to string }{
|
||||
{"divided by", "/"},
|
||||
{"to the power of", "**"},
|
||||
{"raised to", "**"},
|
||||
{"times", "*"},
|
||||
{"multiplied by", "*"},
|
||||
{"plus", "+"},
|
||||
{"minus", "-"},
|
||||
{"mod", "%"},
|
||||
}
|
||||
|
||||
result := s
|
||||
for _, r := range replacements {
|
||||
result = strings.ReplaceAll(strings.ToLower(result), r.from, r.to)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
// formatCalcResult formats a calculation result, adding commas for large numbers.
|
||||
func formatCalcResult(v interface{}) string {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return formatNumber(n)
|
||||
case int64:
|
||||
return formatNumberInt64(n)
|
||||
case float64:
|
||||
if n == math.Trunc(n) && !math.IsInf(n, 0) && !math.IsNaN(n) && math.Abs(n) < 1e15 {
|
||||
// Whole number - format without decimals
|
||||
return formatNumberInt64(int64(n))
|
||||
}
|
||||
// Format with decimals, then add commas to the integer part
|
||||
formatted := fmt.Sprintf("%g", n)
|
||||
parts := strings.SplitN(formatted, ".", 2)
|
||||
// Try to parse the integer part for comma formatting
|
||||
if len(parts[0]) > 3 {
|
||||
negative := strings.HasPrefix(parts[0], "-")
|
||||
digits := parts[0]
|
||||
if negative {
|
||||
digits = digits[1:]
|
||||
}
|
||||
var result strings.Builder
|
||||
remainder := len(digits) % 3
|
||||
if remainder > 0 {
|
||||
result.WriteString(digits[:remainder])
|
||||
}
|
||||
for i := remainder; i < len(digits); i += 3 {
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(',')
|
||||
}
|
||||
result.WriteString(digits[i : i+3])
|
||||
}
|
||||
prefix := ""
|
||||
if negative {
|
||||
prefix = "-"
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return prefix + result.String() + "." + parts[1]
|
||||
}
|
||||
return prefix + result.String()
|
||||
}
|
||||
return formatted
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// formatNumberInt64 adds commas to an int64 for display.
|
||||
func formatNumberInt64(n int64) string {
|
||||
if n < 0 {
|
||||
return "-" + formatNumberInt64(-n)
|
||||
}
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var result strings.Builder
|
||||
remainder := len(s) % 3
|
||||
if remainder > 0 {
|
||||
result.WriteString(s[:remainder])
|
||||
}
|
||||
for i := remainder; i < len(s); i += 3 {
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(',')
|
||||
}
|
||||
result.WriteString(s[i : i+3])
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (p *ToolsPlugin) handleQR(ctx MessageContext) error {
|
||||
text := strings.TrimSpace(p.GetArgs(ctx.Body, "qr"))
|
||||
if text == "" {
|
||||
return p.SendMessage(ctx.RoomID, "Usage: !qr <text or URL>")
|
||||
}
|
||||
|
||||
if len(text) > 2048 {
|
||||
return p.SendMessage(ctx.RoomID, "Text is too long for a QR code (max 2048 characters).")
|
||||
}
|
||||
|
||||
// Generate QR code PNG
|
||||
pngData, err := qrcode.Encode(text, qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
slog.Error("qr: generate failed", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to generate QR code.")
|
||||
}
|
||||
|
||||
// Upload to Matrix
|
||||
mxcURI, err := p.UploadContent(pngData, "image/png", "qrcode.png")
|
||||
if err != nil {
|
||||
slog.Error("qr: upload failed", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to upload QR code image.")
|
||||
}
|
||||
|
||||
// Send as image message
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgImage,
|
||||
Body: "qrcode.png",
|
||||
URL: id.ContentURIString(mxcURI.String()),
|
||||
Info: &event.FileInfo{
|
||||
MimeType: "image/png",
|
||||
Size: len(pngData),
|
||||
},
|
||||
}
|
||||
|
||||
_, err = p.Client.SendMessageEvent(context.Background(), ctx.RoomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
slog.Error("qr: send image failed", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to send QR code image.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
547
internal/plugin/trivia.go
Normal file
547
internal/plugin/trivia.go
Normal file
@@ -0,0 +1,547 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// categoryMap maps category IDs to human-readable names.
|
||||
var categoryMap = map[int]string{
|
||||
9: "General Knowledge",
|
||||
10: "Books",
|
||||
11: "Film",
|
||||
12: "Music",
|
||||
15: "Video Games",
|
||||
17: "Science & Nature",
|
||||
18: "Computers",
|
||||
21: "Sports",
|
||||
22: "Geography",
|
||||
23: "History",
|
||||
}
|
||||
|
||||
// categoryNameToID maps lowercase category names to IDs.
|
||||
var categoryNameToID = map[string]int{
|
||||
"general": 9,
|
||||
"books": 10,
|
||||
"film": 11,
|
||||
"movie": 11,
|
||||
"movies": 11,
|
||||
"music": 12,
|
||||
"games": 15,
|
||||
"gaming": 15,
|
||||
"science": 17,
|
||||
"computers": 18,
|
||||
"tech": 18,
|
||||
"sports": 21,
|
||||
"geography": 22,
|
||||
"geo": 22,
|
||||
"history": 23,
|
||||
}
|
||||
|
||||
type openTDBResponse struct {
|
||||
ResponseCode int `json:"response_code"`
|
||||
Results []openTDBResult `json:"results"`
|
||||
}
|
||||
|
||||
type openTDBResult struct {
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Question string `json:"question"`
|
||||
CorrectAnswer string `json:"correct_answer"`
|
||||
IncorrectAnswers []string `json:"incorrect_answers"`
|
||||
}
|
||||
|
||||
type activeSession struct {
|
||||
RoomID id.RoomID
|
||||
QuestionEventID id.EventID
|
||||
Question string
|
||||
CorrectAnswer string
|
||||
AllAnswers []string
|
||||
CorrectIndex int
|
||||
StartedAt time.Time
|
||||
Difficulty string
|
||||
Category string
|
||||
}
|
||||
|
||||
// TriviaPlugin handles trivia game sessions.
|
||||
type TriviaPlugin struct {
|
||||
Base
|
||||
mu sync.Mutex
|
||||
sessions map[id.RoomID]*activeSession
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewTriviaPlugin creates a new TriviaPlugin.
|
||||
func NewTriviaPlugin(client *mautrix.Client) *TriviaPlugin {
|
||||
return &TriviaPlugin{
|
||||
Base: NewBase(client),
|
||||
sessions: make(map[id.RoomID]*activeSession),
|
||||
enabled: os.Getenv("FEATURE_TRIVIA") != "false",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TriviaPlugin) Name() string { return "trivia" }
|
||||
|
||||
func (p *TriviaPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "trivia", Description: "Start a trivia question", Usage: "!trivia [category] [easy|medium|hard]", Category: "Fun & Games"},
|
||||
{Name: "trivia scores", Description: "Room trivia leaderboard", Usage: "!trivia scores", Category: "Fun & Games"},
|
||||
{Name: "trivia categories", Description: "List available categories", Usage: "!trivia categories", Category: "Fun & Games"},
|
||||
{Name: "trivia fastest", Description: "Fastest correct answers", Usage: "!trivia fastest", Category: "Fun & Games"},
|
||||
{Name: "trivia stop", Description: "Stop current trivia session", Usage: "!trivia stop", Category: "Fun & Games"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TriviaPlugin) Init() error { return nil }
|
||||
|
||||
func (p *TriviaPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *TriviaPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.enabled {
|
||||
if p.IsCommand(ctx.Body, "trivia") {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Trivia is disabled.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.IsCommand(ctx.Body, "trivia") {
|
||||
args := p.GetArgs(ctx.Body, "trivia")
|
||||
return p.handleTrivia(ctx, args)
|
||||
}
|
||||
|
||||
// Check if this is a thread reply to an active trivia question
|
||||
p.mu.Lock()
|
||||
session, ok := p.sessions[ctx.RoomID]
|
||||
p.mu.Unlock()
|
||||
|
||||
if ok && session != nil {
|
||||
// Only accept answers in the trivia thread
|
||||
content := ctx.Event.Content.AsMessage()
|
||||
if content != nil && content.RelatesTo != nil &&
|
||||
content.RelatesTo.Type == event.RelThread &&
|
||||
content.RelatesTo.EventID == session.QuestionEventID {
|
||||
return p.handleAnswer(ctx, session)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TriviaPlugin) handleTrivia(ctx MessageContext, args string) error {
|
||||
lower := strings.ToLower(strings.TrimSpace(args))
|
||||
|
||||
switch {
|
||||
case lower == "scores":
|
||||
return p.showScores(ctx)
|
||||
case lower == "categories":
|
||||
return p.showCategories(ctx)
|
||||
case lower == "fastest":
|
||||
return p.showFastest(ctx)
|
||||
case lower == "stop":
|
||||
return p.stopSession(ctx)
|
||||
default:
|
||||
return p.startQuestion(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TriviaPlugin) showCategories(ctx MessageContext) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📚 Trivia Categories:\n")
|
||||
for catID, name := range categoryMap {
|
||||
sb.WriteString(fmt.Sprintf(" • %s (%d)\n", name, catID))
|
||||
}
|
||||
sb.WriteString("\nUsage: !trivia [category] [easy|medium|hard]")
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
|
||||
func (p *TriviaPlugin) showScores(ctx MessageContext) error {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT user_id, correct, wrong, total_score
|
||||
FROM trivia_scores
|
||||
WHERE room_id = ?
|
||||
ORDER BY total_score DESC
|
||||
LIMIT 10`,
|
||||
string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("trivia: query scores", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to fetch scores.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🏆 Trivia Leaderboard:\n")
|
||||
rank := 0
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var correct, wrong, totalScore int
|
||||
if err := rows.Scan(&userID, &correct, &wrong, &totalScore); err != nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
rank++
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %d pts (%d correct, %d wrong)\n", rank, userID, totalScore, correct, wrong))
|
||||
}
|
||||
|
||||
if !found {
|
||||
return p.SendMessage(ctx.RoomID, "No trivia scores yet in this room!")
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
|
||||
func (p *TriviaPlugin) showFastest(ctx MessageContext) error {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT user_id, fastest_ms
|
||||
FROM trivia_scores
|
||||
WHERE room_id = ? AND fastest_ms > 0
|
||||
ORDER BY fastest_ms ASC
|
||||
LIMIT 10`,
|
||||
string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("trivia: query fastest", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to fetch fastest times.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("⚡ Fastest Correct Answers:\n")
|
||||
rank := 0
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var fastestMs int
|
||||
if err := rows.Scan(&userID, &fastestMs); err != nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
rank++
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %.2fs\n", rank, userID, float64(fastestMs)/1000.0))
|
||||
}
|
||||
|
||||
if !found {
|
||||
return p.SendMessage(ctx.RoomID, "No fastest times recorded yet!")
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
|
||||
func (p *TriviaPlugin) stopSession(ctx MessageContext) error {
|
||||
p.mu.Lock()
|
||||
_, ok := p.sessions[ctx.RoomID]
|
||||
if ok {
|
||||
delete(p.sessions, ctx.RoomID)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return p.SendMessage(ctx.RoomID, "No active trivia session in this room.")
|
||||
}
|
||||
return p.SendMessage(ctx.RoomID, "Trivia session stopped.")
|
||||
}
|
||||
|
||||
func (p *TriviaPlugin) startQuestion(ctx MessageContext, args string) error {
|
||||
p.mu.Lock()
|
||||
if _, ok := p.sessions[ctx.RoomID]; ok {
|
||||
p.mu.Unlock()
|
||||
return p.SendMessage(ctx.RoomID, "A trivia question is already active! Answer it or use !trivia stop.")
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Parse category and difficulty from args
|
||||
category := 0
|
||||
difficulty := ""
|
||||
parts := strings.Fields(strings.ToLower(args))
|
||||
|
||||
for _, part := range parts {
|
||||
if part == "easy" || part == "medium" || part == "hard" {
|
||||
difficulty = part
|
||||
continue
|
||||
}
|
||||
if catID, ok := categoryNameToID[part]; ok {
|
||||
category = catID
|
||||
}
|
||||
}
|
||||
|
||||
// Build API URL
|
||||
apiURL := "https://opentdb.com/api.php?amount=1"
|
||||
if category > 0 {
|
||||
apiURL += fmt.Sprintf("&category=%d", category)
|
||||
}
|
||||
if difficulty != "" {
|
||||
apiURL += fmt.Sprintf("&difficulty=%s", difficulty)
|
||||
}
|
||||
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
slog.Error("trivia: API request failed", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to fetch trivia question. Try again later.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("trivia: read response", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to read trivia response.")
|
||||
}
|
||||
|
||||
var apiResp openTDBResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
slog.Error("trivia: parse response", "err", err)
|
||||
return p.SendMessage(ctx.RoomID, "Failed to parse trivia response.")
|
||||
}
|
||||
|
||||
if apiResp.ResponseCode != 0 || len(apiResp.Results) == 0 {
|
||||
return p.SendMessage(ctx.RoomID, "No trivia questions available for those criteria. Try different options!")
|
||||
}
|
||||
|
||||
result := apiResp.Results[0]
|
||||
question := html.UnescapeString(result.Question)
|
||||
correctAnswer := html.UnescapeString(result.CorrectAnswer)
|
||||
|
||||
// Build answer list
|
||||
var allAnswers []string
|
||||
correctIndex := 0
|
||||
|
||||
if result.Type == "boolean" {
|
||||
allAnswers = []string{"True", "False"}
|
||||
if correctAnswer == "False" {
|
||||
correctIndex = 1
|
||||
}
|
||||
} else {
|
||||
// Shuffle answers
|
||||
incorrectAnswers := make([]string, len(result.IncorrectAnswers))
|
||||
for i, a := range result.IncorrectAnswers {
|
||||
incorrectAnswers[i] = html.UnescapeString(a)
|
||||
}
|
||||
allAnswers = append(incorrectAnswers, correctAnswer)
|
||||
// Fisher-Yates shuffle
|
||||
for i := len(allAnswers) - 1; i > 0; i-- {
|
||||
j := rand.IntN(i + 1)
|
||||
allAnswers[i], allAnswers[j] = allAnswers[j], allAnswers[i]
|
||||
}
|
||||
for i, a := range allAnswers {
|
||||
if a == correctAnswer {
|
||||
correctIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format question message
|
||||
diffLabel := html.UnescapeString(result.Difficulty)
|
||||
catLabel := html.UnescapeString(result.Category)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🧠 **Trivia** [%s / %s]\n\n", catLabel, diffLabel))
|
||||
sb.WriteString(fmt.Sprintf("%s\n\n", question))
|
||||
|
||||
letters := []string{"A", "B", "C", "D"}
|
||||
for i, ans := range allAnswers {
|
||||
if i < len(letters) {
|
||||
sb.WriteString(fmt.Sprintf(" **%s.** %s\n", letters[i], ans))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\nReply with A, B, C, or D (or True/False). You have 30 seconds!")
|
||||
|
||||
msgText := sb.String()
|
||||
|
||||
// Store in DB
|
||||
incorrectJSON, _ := json.Marshal(result.IncorrectAnswers)
|
||||
_, err = db.Get().Exec(
|
||||
`INSERT INTO trivia_sessions (room_id, category, difficulty, question, correct_answer, incorrect_answers, question_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
string(ctx.RoomID), category, result.Difficulty, question, correctAnswer, string(incorrectJSON), result.Type,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("trivia: insert session", "err", err)
|
||||
}
|
||||
|
||||
// Send thread root announcement, then post the question in the thread
|
||||
threadRootID, err := p.SendMessageID(ctx.RoomID, "🧠 Trivia time! Reply in thread to answer.")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.SendThread(ctx.RoomID, threadRootID, msgText); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session := &activeSession{
|
||||
RoomID: ctx.RoomID,
|
||||
QuestionEventID: threadRootID,
|
||||
Question: question,
|
||||
CorrectAnswer: correctAnswer,
|
||||
AllAnswers: allAnswers,
|
||||
CorrectIndex: correctIndex,
|
||||
StartedAt: time.Now(),
|
||||
Difficulty: result.Difficulty,
|
||||
Category: catLabel,
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.sessions[ctx.RoomID] = session
|
||||
p.mu.Unlock()
|
||||
|
||||
// Auto-expire after 30 seconds
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
p.mu.Lock()
|
||||
current, ok := p.sessions[ctx.RoomID]
|
||||
if ok && current == session {
|
||||
delete(p.sessions, ctx.RoomID)
|
||||
p.mu.Unlock()
|
||||
letters := []string{"A", "B", "C", "D"}
|
||||
ansLetter := ""
|
||||
if session.CorrectIndex < len(letters) {
|
||||
ansLetter = letters[session.CorrectIndex] + ". "
|
||||
}
|
||||
_ = p.SendThread(ctx.RoomID, session.QuestionEventID, fmt.Sprintf("⏰ Time's up! The correct answer was: **%s%s**", ansLetter, session.CorrectAnswer))
|
||||
} else {
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TriviaPlugin) handleAnswer(ctx MessageContext, session *activeSession) error {
|
||||
answer := strings.TrimSpace(strings.ToLower(ctx.Body))
|
||||
|
||||
// Map letter answers to index
|
||||
answerIndex := -1
|
||||
switch answer {
|
||||
case "a":
|
||||
answerIndex = 0
|
||||
case "b":
|
||||
answerIndex = 1
|
||||
case "c":
|
||||
answerIndex = 2
|
||||
case "d":
|
||||
answerIndex = 3
|
||||
case "true":
|
||||
// Find "True" in answers
|
||||
for i, a := range session.AllAnswers {
|
||||
if strings.ToLower(a) == "true" {
|
||||
answerIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
case "false":
|
||||
for i, a := range session.AllAnswers {
|
||||
if strings.ToLower(a) == "false" {
|
||||
answerIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Not a trivia answer, ignore
|
||||
return nil
|
||||
}
|
||||
|
||||
if answerIndex < 0 || answerIndex >= len(session.AllAnswers) {
|
||||
return nil
|
||||
}
|
||||
|
||||
elapsed := time.Since(session.StartedAt)
|
||||
elapsedMs := elapsed.Milliseconds()
|
||||
|
||||
correct := answerIndex == session.CorrectIndex
|
||||
|
||||
// Remove session
|
||||
p.mu.Lock()
|
||||
current, ok := p.sessions[ctx.RoomID]
|
||||
if !ok || current != session {
|
||||
p.mu.Unlock()
|
||||
return nil // Session already ended
|
||||
}
|
||||
delete(p.sessions, ctx.RoomID)
|
||||
p.mu.Unlock()
|
||||
|
||||
if correct {
|
||||
// Calculate score: 100 at 3s, scaling to 0 at 30s
|
||||
score := calculateScore(elapsed)
|
||||
|
||||
// Update scores in DB
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO trivia_scores (user_id, room_id, correct, wrong, total_score, fastest_ms)
|
||||
VALUES (?, ?, 1, 0, ?, ?)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
correct = correct + 1,
|
||||
total_score = total_score + ?,
|
||||
fastest_ms = CASE WHEN fastest_ms = 0 OR ? < fastest_ms THEN ? ELSE fastest_ms END`,
|
||||
string(ctx.Sender), string(ctx.RoomID), score, elapsedMs,
|
||||
score, elapsedMs, elapsedMs,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("trivia: update score", "err", err)
|
||||
}
|
||||
|
||||
// Update session record (use subquery since SQLite doesn't support UPDATE...ORDER BY...LIMIT)
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE trivia_sessions SET ended = 1, winner_id = ?, winner_time_ms = ?
|
||||
WHERE id = (SELECT id FROM trivia_sessions WHERE room_id = ? AND ended = 0 ORDER BY started_at DESC LIMIT 1)`,
|
||||
string(ctx.Sender), elapsedMs, string(ctx.RoomID),
|
||||
); err != nil {
|
||||
slog.Error("trivia: update session", "err", err)
|
||||
}
|
||||
|
||||
return p.SendThread(ctx.RoomID, session.QuestionEventID,
|
||||
fmt.Sprintf("✅ Correct! %s answered in %.2fs for %d points!", ctx.Sender, float64(elapsedMs)/1000.0, score))
|
||||
}
|
||||
|
||||
// Wrong answer
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO trivia_scores (user_id, room_id, correct, wrong, total_score, fastest_ms)
|
||||
VALUES (?, ?, 0, 1, 0, 0)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
wrong = wrong + 1`,
|
||||
string(ctx.Sender), string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("trivia: update wrong score", "err", err)
|
||||
}
|
||||
|
||||
letters := []string{"A", "B", "C", "D"}
|
||||
ansLetter := ""
|
||||
if session.CorrectIndex < len(letters) {
|
||||
ansLetter = letters[session.CorrectIndex] + ". "
|
||||
}
|
||||
|
||||
return p.SendThread(ctx.RoomID, session.QuestionEventID,
|
||||
fmt.Sprintf("❌ Wrong! The correct answer was: **%s%s**", ansLetter, session.CorrectAnswer))
|
||||
}
|
||||
|
||||
// calculateScore returns time-weighted points: 100 at 3s, scaling linearly to 0 at 30s.
|
||||
func calculateScore(elapsed time.Duration) int {
|
||||
secs := elapsed.Seconds()
|
||||
if secs <= 3.0 {
|
||||
return 100
|
||||
}
|
||||
if secs >= 30.0 {
|
||||
return 0
|
||||
}
|
||||
// Linear interpolation: 100 at 3s -> 0 at 30s
|
||||
score := int(100.0 * (30.0 - secs) / 27.0)
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
return score
|
||||
}
|
||||
187
internal/plugin/urls.go
Normal file
187
internal/plugin/urls.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
|
||||
var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`)
|
||||
|
||||
// URLsPlugin detects URLs in messages and previews og:title/og:description.
|
||||
type URLsPlugin struct {
|
||||
Base
|
||||
enabled bool
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewURLsPlugin creates a new URL preview plugin.
|
||||
func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
|
||||
enabled := os.Getenv("FEATURE_URL_PREVIEW") != ""
|
||||
return &URLsPlugin{
|
||||
Base: NewBase(client),
|
||||
enabled: enabled,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *URLsPlugin) Name() string { return "urls" }
|
||||
|
||||
func (p *URLsPlugin) Commands() []CommandDef {
|
||||
return nil // No commands — purely passive
|
||||
}
|
||||
|
||||
func (p *URLsPlugin) Init() error { return nil }
|
||||
|
||||
func (p *URLsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip command messages
|
||||
if ctx.IsCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
urls := urlRe.FindAllString(ctx.Body, 5) // limit to 5 URLs per message
|
||||
if len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, u := range urls {
|
||||
title, desc, err := p.fetchPreview(u)
|
||||
if err != nil {
|
||||
slog.Debug("urls: fetch preview failed", "url", u, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if title == "" && desc == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var preview strings.Builder
|
||||
if title != "" {
|
||||
preview.WriteString(fmt.Sprintf("Title: %s", title))
|
||||
}
|
||||
if desc != "" {
|
||||
if preview.Len() > 0 {
|
||||
preview.WriteString("\n")
|
||||
}
|
||||
// Truncate long descriptions
|
||||
if len(desc) > 200 {
|
||||
desc = desc[:200] + "..."
|
||||
}
|
||||
preview.WriteString(fmt.Sprintf("Description: %s", desc))
|
||||
}
|
||||
|
||||
if err := p.SendReply(ctx.RoomID, ctx.EventID, preview.String()); err != nil {
|
||||
slog.Error("urls: send preview", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchPreview retrieves og:title and og:description, checking cache first.
|
||||
func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) {
|
||||
d := db.Get()
|
||||
now := time.Now().UTC().Unix()
|
||||
cacheTTL := int64(24 * 60 * 60)
|
||||
|
||||
// Check cache
|
||||
var title, desc string
|
||||
var cachedAt int64
|
||||
err := d.QueryRow(
|
||||
`SELECT title, description, cached_at FROM url_cache WHERE url = ?`, rawURL,
|
||||
).Scan(&title, &desc, &cachedAt)
|
||||
if err == nil && now-cachedAt < cacheTTL {
|
||||
return title, desc, nil
|
||||
}
|
||||
|
||||
// Fetch from web
|
||||
title, desc, err = p.scrapeOG(rawURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
_, cacheErr := d.Exec(
|
||||
`INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`,
|
||||
rawURL, title, desc, now, title, desc, now,
|
||||
)
|
||||
if cacheErr != nil {
|
||||
slog.Error("urls: cache write", "err", cacheErr)
|
||||
}
|
||||
|
||||
return title, desc, nil
|
||||
}
|
||||
|
||||
// scrapeOG fetches a URL and extracts og:title and og:description.
|
||||
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "GogoBee Bot/1.0")
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse HTML: %w", err)
|
||||
}
|
||||
|
||||
title := ""
|
||||
desc := ""
|
||||
|
||||
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
|
||||
prop, _ := s.Attr("property")
|
||||
content, _ := s.Attr("content")
|
||||
switch prop {
|
||||
case "og:title":
|
||||
title = content
|
||||
case "og:description":
|
||||
desc = content
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback to <title> tag if no og:title
|
||||
if title == "" {
|
||||
title = doc.Find("title").First().Text()
|
||||
}
|
||||
|
||||
// Fallback to meta description
|
||||
if desc == "" {
|
||||
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
|
||||
name, _ := s.Attr("name")
|
||||
if strings.EqualFold(name, "description") {
|
||||
content, _ := s.Attr("content")
|
||||
desc = content
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return strings.TrimSpace(title), strings.TrimSpace(desc), nil
|
||||
}
|
||||
452
internal/plugin/user.go
Normal file
452
internal/plugin/user.go
Normal file
@@ -0,0 +1,452 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// UserPlugin provides personal utility commands: timezone, quotes, now-playing, backlog, keyword watches.
|
||||
type UserPlugin struct {
|
||||
Base
|
||||
}
|
||||
|
||||
// NewUserPlugin creates a new user plugin.
|
||||
func NewUserPlugin(client *mautrix.Client) *UserPlugin {
|
||||
return &UserPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UserPlugin) Name() string { return "user" }
|
||||
|
||||
func (p *UserPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "settz", Description: "Set your timezone (IANA format)", Usage: "!settz America/New_York", Category: "Personal"},
|
||||
{Name: "mytz", Description: "Show your timezone and current time", Usage: "!mytz", Category: "Personal"},
|
||||
{Name: "timezone", Description: "List common timezones", Usage: "!timezone list", Category: "Personal"},
|
||||
{Name: "quote", Description: "Show a random saved quote from this room", Usage: "!quote", Category: "Personal"},
|
||||
{Name: "np", Description: "Set or show now playing", Usage: "!np [track]", Category: "Personal"},
|
||||
{Name: "backlog", Description: "Manage your personal backlog", Usage: "!backlog add/list/random/done", Category: "Personal"},
|
||||
{Name: "watch", Description: "Watch a keyword for DM alerts", Usage: "!watch <keyword>", Category: "Personal"},
|
||||
{Name: "watching", Description: "List your keyword watches", Usage: "!watching", Category: "Personal"},
|
||||
{Name: "unwatch", Description: "Remove a keyword watch", Usage: "!unwatch <keyword>", Category: "Personal"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UserPlugin) Init() error { return nil }
|
||||
|
||||
func (p *UserPlugin) OnReaction(ctx ReactionContext) error {
|
||||
if ctx.Emoji != "\u2B50" { // ⭐
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch the target event to get the quoted message
|
||||
evt, err := p.Client.GetEvent(context.Background(), ctx.RoomID, ctx.TargetEvent)
|
||||
if err != nil {
|
||||
slog.Error("user: fetch event for quote", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := evt.Content.ParseRaw(evt.Type); err != nil {
|
||||
slog.Error("user: parse event content for quote", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
content := evt.Content.AsMessage()
|
||||
if content == nil || content.Body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO quotes (room_id, user_id, quote_text, saved_by) VALUES (?, ?, ?, ?)`,
|
||||
string(ctx.RoomID), string(evt.Sender), content.Body, string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("user: save quote", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.SendReact(ctx.RoomID, ctx.EventID, "\u2705") // ✅
|
||||
}
|
||||
|
||||
func (p *UserPlugin) OnMessage(ctx MessageContext) error {
|
||||
switch {
|
||||
case p.IsCommand(ctx.Body, "settz"):
|
||||
return p.handleSetTZ(ctx)
|
||||
case p.IsCommand(ctx.Body, "mytz"):
|
||||
return p.handleMyTZ(ctx)
|
||||
case p.IsCommand(ctx.Body, "timezone"):
|
||||
return p.handleTimezoneList(ctx)
|
||||
case p.IsCommand(ctx.Body, "quote"):
|
||||
return p.handleQuote(ctx)
|
||||
case p.IsCommand(ctx.Body, "np"):
|
||||
return p.handleNP(ctx)
|
||||
case p.IsCommand(ctx.Body, "backlog"):
|
||||
return p.handleBacklog(ctx)
|
||||
case p.IsCommand(ctx.Body, "watch"):
|
||||
return p.handleWatch(ctx)
|
||||
case p.IsCommand(ctx.Body, "watching"):
|
||||
return p.handleWatching(ctx)
|
||||
case p.IsCommand(ctx.Body, "unwatch"):
|
||||
return p.handleUnwatch(ctx)
|
||||
}
|
||||
|
||||
// Passive: check keyword watches
|
||||
p.checkKeywordWatches(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *UserPlugin) handleSetTZ(ctx MessageContext) error {
|
||||
tz := strings.TrimSpace(p.GetArgs(ctx.Body, "settz"))
|
||||
if tz == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !settz <IANA timezone> (e.g. America/New_York)")
|
||||
}
|
||||
|
||||
_, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid timezone: %s. Use IANA format like America/New_York.", tz))
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO birthdays (user_id, month, day, timezone) VALUES (?, 0, 0, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET timezone = ?`,
|
||||
string(ctx.Sender), tz, tz,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("user: set timezone", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save timezone.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Timezone set to %s.", tz))
|
||||
}
|
||||
|
||||
func (p *UserPlugin) handleMyTZ(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
var tz string
|
||||
err := d.QueryRow(
|
||||
`SELECT timezone FROM birthdays WHERE user_id = ?`,
|
||||
string(ctx.Sender),
|
||||
).Scan(&tz)
|
||||
if err != nil || tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
tz = "UTC"
|
||||
}
|
||||
|
||||
now := time.Now().In(loc)
|
||||
msg := fmt.Sprintf("Your timezone: %s\nCurrent time: %s", tz, now.Format("Monday, January 2, 2006 3:04 PM"))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *UserPlugin) handleTimezoneList(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "timezone"))
|
||||
if args != "list" {
|
||||
return nil
|
||||
}
|
||||
|
||||
zones := []string{
|
||||
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||
"America/Sao_Paulo", "Europe/London", "Europe/Paris", "Europe/Berlin",
|
||||
"Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Asia/Seoul",
|
||||
"Australia/Sydney", "Pacific/Auckland", "UTC",
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Common timezones:\n\n")
|
||||
for _, z := range zones {
|
||||
loc, err := time.LoadLocation(z)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
sb.WriteString(fmt.Sprintf(" %s — %s\n", z, now.Format("3:04 PM")))
|
||||
}
|
||||
sb.WriteString("\nSet yours with: !settz <timezone>")
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *UserPlugin) handleQuote(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
var quoteText, userID string
|
||||
err := d.QueryRow(
|
||||
`SELECT quote_text, user_id FROM quotes WHERE room_id = ? ORDER BY RANDOM() LIMIT 1`,
|
||||
string(ctx.RoomID),
|
||||
).Scan("eText, &userID)
|
||||
if err == sql.ErrNoRows {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No quotes saved in this room yet. React with a star to save one!")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("user: random quote", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch a quote.")
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("\"%s\"\n — %s", quoteText, userID)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *UserPlugin) handleNP(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "np"))
|
||||
d := db.Get()
|
||||
|
||||
if args == "" {
|
||||
// Show current now playing
|
||||
var track string
|
||||
var updatedAt int64
|
||||
err := d.QueryRow(
|
||||
`SELECT track, updated_at FROM now_playing WHERE user_id = ?`,
|
||||
string(ctx.Sender),
|
||||
).Scan(&track, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You don't have anything playing. Use !np <track> to set it.")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("user: get np", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch now playing.")
|
||||
}
|
||||
t := time.Unix(updatedAt, 0).UTC()
|
||||
msg := fmt.Sprintf("Now playing for %s: %s (set %s)", string(ctx.Sender), track, t.Format("Jan 2 3:04 PM UTC"))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
// Set now playing
|
||||
now := time.Now().UTC().Unix()
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO now_playing (user_id, track, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET track = ?, updated_at = ?`,
|
||||
string(ctx.Sender), args, now, args, now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("user: set np", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save now playing.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Now playing: %s", args))
|
||||
}
|
||||
|
||||
func (p *UserPlugin) handleBacklog(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "backlog"))
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !backlog add <item> | list | random | done <id>")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
|
||||
if strings.HasPrefix(strings.ToLower(args), "add ") {
|
||||
item := strings.TrimSpace(args[4:])
|
||||
if item == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Please provide an item to add.")
|
||||
}
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO backlog (user_id, item) VALUES (?, ?)`,
|
||||
string(ctx.Sender), item,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("user: backlog add", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to backlog.")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Added to backlog: %s", item))
|
||||
}
|
||||
|
||||
if strings.ToLower(args) == "list" {
|
||||
rows, err := d.Query(
|
||||
`SELECT id, item FROM backlog WHERE user_id = ? AND done = 0 ORDER BY created_at DESC`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("user: backlog list", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load backlog.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Your backlog:\n\n")
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var itemID int
|
||||
var item string
|
||||
if err := rows.Scan(&itemID, &item); err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s\n", itemID, item))
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Your backlog is empty!")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
if strings.ToLower(args) == "random" {
|
||||
var itemID int
|
||||
var item string
|
||||
err := d.QueryRow(
|
||||
`SELECT id, item FROM backlog WHERE user_id = ? AND done = 0 ORDER BY RANDOM() LIMIT 1`,
|
||||
string(ctx.Sender),
|
||||
).Scan(&itemID, &item)
|
||||
if err == sql.ErrNoRows {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Your backlog is empty!")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("user: backlog random", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to pick from backlog.")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Random pick from your backlog: [%d] %s", itemID, item))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(strings.ToLower(args), "done ") {
|
||||
idStr := strings.TrimSpace(args[5:])
|
||||
result, err := d.Exec(
|
||||
`UPDATE backlog SET done = 1 WHERE id = ? AND user_id = ?`,
|
||||
idStr, string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("user: backlog done", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to mark as done.")
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Item not found or not yours.")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Marked as done!")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !backlog add <item> | list | random | done <id>")
|
||||
}
|
||||
|
||||
func (p *UserPlugin) handleWatch(ctx MessageContext) error {
|
||||
keyword := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "watch")))
|
||||
if keyword == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !watch <keyword>")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
_, err := d.Exec(
|
||||
`INSERT OR IGNORE INTO keyword_watches (user_id, keyword, room_id) VALUES (?, ?, ?)`,
|
||||
string(ctx.Sender), keyword, string(ctx.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("user: add watch", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add keyword watch.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Watching for \"%s\". You'll get a DM when it's mentioned.", keyword))
|
||||
}
|
||||
|
||||
func (p *UserPlugin) handleWatching(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT keyword FROM keyword_watches WHERE user_id = ?`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("user: list watches", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watches.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keywords []string
|
||||
for rows.Next() {
|
||||
var kw string
|
||||
if err := rows.Scan(&kw); err != nil {
|
||||
continue
|
||||
}
|
||||
keywords = append(keywords, kw)
|
||||
}
|
||||
|
||||
if len(keywords) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not watching any keywords. Use !watch <keyword> to start.")
|
||||
}
|
||||
|
||||
msg := "Your keyword watches:\n\n"
|
||||
for _, kw := range keywords {
|
||||
msg += fmt.Sprintf(" - %s\n", kw)
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *UserPlugin) handleUnwatch(ctx MessageContext) error {
|
||||
keyword := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "unwatch")))
|
||||
if keyword == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !unwatch <keyword>")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
result, err := d.Exec(
|
||||
`DELETE FROM keyword_watches WHERE user_id = ? AND keyword = ?`,
|
||||
string(ctx.Sender), keyword,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("user: remove watch", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove keyword watch.")
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("You weren't watching \"%s\".", keyword))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Stopped watching \"%s\".", keyword))
|
||||
}
|
||||
|
||||
func (p *UserPlugin) checkKeywordWatches(ctx MessageContext) {
|
||||
// Skip bot commands
|
||||
if ctx.IsCommand {
|
||||
return
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
bodyLower := strings.ToLower(ctx.Body)
|
||||
|
||||
rows, err := d.Query(`SELECT user_id, keyword FROM keyword_watches WHERE room_id = ?`, string(ctx.RoomID))
|
||||
if err != nil {
|
||||
slog.Error("user: check watches", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var watcherID, keyword string
|
||||
if err := rows.Scan(&watcherID, &keyword); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Don't alert the sender about their own messages
|
||||
if id.UserID(watcherID) == ctx.Sender {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(bodyLower, keyword) {
|
||||
preview := ctx.Body
|
||||
if len(preview) > 100 {
|
||||
preview = preview[:100] + "..."
|
||||
}
|
||||
dmMsg := fmt.Sprintf("Keyword alert: \"%s\" was mentioned by %s in %s:\n\n%s",
|
||||
keyword, string(ctx.Sender), string(ctx.RoomID), preview)
|
||||
|
||||
// Use a goroutine to avoid blocking message dispatch
|
||||
go func(uid id.UserID, msg string) {
|
||||
if err := p.SendDM(uid, msg); err != nil {
|
||||
slog.Error("user: send watch DM", "err", err, "user", uid)
|
||||
}
|
||||
}(id.UserID(watcherID), dmMsg)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
221
internal/plugin/vibe.go
Normal file
221
internal/plugin/vibe.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const (
|
||||
vibeBufferSize = 50
|
||||
vibeCooldownMins = 5
|
||||
vibeMinMessages = 10
|
||||
)
|
||||
|
||||
type bufferedMessage struct {
|
||||
Sender string
|
||||
Body string
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// VibePlugin uses LLM to describe room energy or summarize conversation.
|
||||
type VibePlugin struct {
|
||||
Base
|
||||
mu sync.Mutex
|
||||
buffers map[id.RoomID][]bufferedMessage
|
||||
cooldowns map[id.RoomID]time.Time
|
||||
}
|
||||
|
||||
// NewVibePlugin creates a new vibe plugin.
|
||||
func NewVibePlugin(client *mautrix.Client) *VibePlugin {
|
||||
return &VibePlugin{
|
||||
Base: NewBase(client),
|
||||
buffers: make(map[id.RoomID][]bufferedMessage),
|
||||
cooldowns: make(map[id.RoomID]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *VibePlugin) Name() string { return "vibe" }
|
||||
|
||||
func (p *VibePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "vibe", Description: "LLM describes the current room energy", Usage: "!vibe", Category: "LLM & Sentiment"},
|
||||
{Name: "tldr", Description: "LLM summarizes recent conversation", Usage: "!tldr", Category: "LLM & Sentiment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *VibePlugin) Init() error { return nil }
|
||||
|
||||
func (p *VibePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *VibePlugin) OnMessage(ctx MessageContext) error {
|
||||
// Always buffer messages (except commands)
|
||||
if !ctx.IsCommand {
|
||||
p.addToBuffer(ctx)
|
||||
}
|
||||
|
||||
if p.IsCommand(ctx.Body, "vibe") {
|
||||
return p.handleVibe(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "tldr") {
|
||||
return p.handleTLDR(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *VibePlugin) addToBuffer(ctx MessageContext) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
buf := p.buffers[ctx.RoomID]
|
||||
buf = append(buf, bufferedMessage{
|
||||
Sender: string(ctx.Sender),
|
||||
Body: ctx.Body,
|
||||
Time: time.Now().UTC(),
|
||||
})
|
||||
|
||||
// Keep only the last N messages
|
||||
if len(buf) > vibeBufferSize {
|
||||
buf = buf[len(buf)-vibeBufferSize:]
|
||||
}
|
||||
|
||||
p.buffers[ctx.RoomID] = buf
|
||||
}
|
||||
|
||||
func (p *VibePlugin) getBuffer(roomID id.RoomID) []bufferedMessage {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
buf := p.buffers[roomID]
|
||||
// Return a copy
|
||||
result := make([]bufferedMessage, len(buf))
|
||||
copy(result, buf)
|
||||
return result
|
||||
}
|
||||
|
||||
func (p *VibePlugin) checkCooldown(roomID id.RoomID) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
last, ok := p.cooldowns[roomID]
|
||||
if ok && time.Since(last) < vibeCooldownMins*time.Minute {
|
||||
return false
|
||||
}
|
||||
p.cooldowns[roomID] = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *VibePlugin) resetCooldown(roomID id.RoomID) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.cooldowns, roomID)
|
||||
}
|
||||
|
||||
func (p *VibePlugin) handleVibe(ctx MessageContext) error {
|
||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
||||
if ollamaHost == "" || ollamaModel == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||
}
|
||||
|
||||
buf := p.getBuffer(ctx.RoomID)
|
||||
if len(buf) < vibeMinMessages {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Need at least %d messages to read the vibe. Currently have %d.", vibeMinMessages, len(buf)))
|
||||
}
|
||||
|
||||
if !p.checkCooldown(ctx.RoomID) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Vibe check is on cooldown. Try again in %d minutes.", vibeCooldownMins))
|
||||
}
|
||||
|
||||
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||
if botName == "" {
|
||||
botName = "GogoBee"
|
||||
}
|
||||
transcript := formatTranscript(buf)
|
||||
prompt := fmt.Sprintf(
|
||||
`You are %s, a fun community bot. Based on the following recent chat messages, describe the current "vibe" or energy of the room in 2-3 sentences. Be creative, playful, and use colorful language. Reference specific topics or dynamics you notice.
|
||||
|
||||
Recent messages:
|
||||
%s
|
||||
|
||||
Describe the room's current vibe:`, botName, transcript)
|
||||
|
||||
if err := p.SendReply(ctx.RoomID, ctx.EventID, "Reading the room..."); err != nil {
|
||||
slog.Error("vibe: send thinking", "err", err)
|
||||
}
|
||||
|
||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||
if err != nil {
|
||||
slog.Error("vibe: ollama call", "err", err)
|
||||
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read the vibe. LLM might be offline.")
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, response)
|
||||
}
|
||||
|
||||
func (p *VibePlugin) handleTLDR(ctx MessageContext) error {
|
||||
ollamaHost := os.Getenv("OLLAMA_HOST")
|
||||
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
||||
if ollamaHost == "" || ollamaModel == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||
}
|
||||
|
||||
buf := p.getBuffer(ctx.RoomID)
|
||||
if len(buf) < vibeMinMessages {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Need at least %d messages for a summary. Currently have %d.", vibeMinMessages, len(buf)))
|
||||
}
|
||||
|
||||
if !p.checkCooldown(ctx.RoomID) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("TLDR is on cooldown. Try again in %d minutes.", vibeCooldownMins))
|
||||
}
|
||||
|
||||
tldrBotName := os.Getenv("BOT_DISPLAY_NAME")
|
||||
if tldrBotName == "" {
|
||||
tldrBotName = "GogoBee"
|
||||
}
|
||||
transcript := formatTranscript(buf)
|
||||
prompt := fmt.Sprintf(
|
||||
`You are %s, a community bot. Summarize the following recent chat conversation in 3-5 concise bullet points. Focus on the main topics discussed, any decisions made, and key moments. Be brief and informative.
|
||||
|
||||
Recent messages:
|
||||
%s
|
||||
|
||||
Summary:`, tldrBotName, transcript)
|
||||
|
||||
if err := p.SendReply(ctx.RoomID, ctx.EventID, "Summarizing the conversation..."); err != nil {
|
||||
slog.Error("vibe: send thinking", "err", err)
|
||||
}
|
||||
|
||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||
if err != nil {
|
||||
slog.Error("vibe: ollama call", "err", err)
|
||||
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to summarize. LLM might be offline.")
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, response)
|
||||
}
|
||||
|
||||
func formatTranscript(messages []bufferedMessage) string {
|
||||
var sb strings.Builder
|
||||
for _, m := range messages {
|
||||
// Truncate long messages
|
||||
body := m.Body
|
||||
if len(body) > 300 {
|
||||
body = body[:300] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%s] %s: %s\n", m.Time.Format("15:04"), m.Sender, body))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
206
internal/plugin/welcome.go
Normal file
206
internal/plugin/welcome.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
|
||||
// WelcomeRegistry is the interface for retrieving all registered commands (for !help).
|
||||
type WelcomeRegistry interface {
|
||||
GetCommands() []CommandDef
|
||||
}
|
||||
|
||||
// WelcomePlugin detects new users and provides the !help command.
|
||||
type WelcomePlugin struct {
|
||||
Base
|
||||
xp *XPPlugin
|
||||
registry WelcomeRegistry
|
||||
}
|
||||
|
||||
// NewWelcomePlugin creates a new welcome plugin.
|
||||
func NewWelcomePlugin(client *mautrix.Client, xp *XPPlugin, registry WelcomeRegistry) *WelcomePlugin {
|
||||
return &WelcomePlugin{
|
||||
Base: NewBase(client),
|
||||
xp: xp,
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WelcomePlugin) Name() string { return "welcome" }
|
||||
|
||||
func (p *WelcomePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "help", Description: "DM you a list of all bot commands", Usage: "!help", Category: "Info"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WelcomePlugin) Init() error { return nil }
|
||||
|
||||
func (p *WelcomePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *WelcomePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "help") {
|
||||
return p.handleHelp(ctx)
|
||||
}
|
||||
|
||||
// Passive: detect new users by checking for "welcome_wagon" achievement
|
||||
p.checkNewUser(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WelcomePlugin) checkNewUser(ctx MessageContext) {
|
||||
d := db.Get()
|
||||
|
||||
var exists int
|
||||
err := d.QueryRow(
|
||||
`SELECT 1 FROM achievements WHERE user_id = ? AND achievement_id = 'welcome_wagon'`,
|
||||
string(ctx.Sender),
|
||||
).Scan(&exists)
|
||||
|
||||
if err == nil {
|
||||
// Already has the achievement, not a new user
|
||||
return
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
slog.Error("welcome: check achievement", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// New user! Grant achievement
|
||||
_, err = d.Exec(
|
||||
`INSERT OR IGNORE INTO achievements (user_id, achievement_id) VALUES (?, 'welcome_wagon')`,
|
||||
string(ctx.Sender),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("welcome: grant achievement", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Grant 25 XP
|
||||
if p.xp != nil {
|
||||
p.xp.GrantXP(ctx.Sender, 25, "welcome")
|
||||
}
|
||||
|
||||
// Send welcome message
|
||||
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||
if botName == "" {
|
||||
botName = "GogoBee"
|
||||
}
|
||||
welcome := fmt.Sprintf(
|
||||
"Welcome to the community, %s! I'm %s, your friendly community bot.\n\n"+
|
||||
"Here are some things you can do:\n"+
|
||||
" - !help — see all available commands\n"+
|
||||
" - !rank — check your XP and level\n"+
|
||||
" - !streak — see your activity streak\n"+
|
||||
" - !trivia — play trivia games\n\n"+
|
||||
"You've been awarded 25 XP as a welcome gift! Have fun!",
|
||||
string(ctx.Sender), botName,
|
||||
)
|
||||
if err := p.SendMessage(ctx.RoomID, welcome); err != nil {
|
||||
slog.Error("welcome: send message", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// categoryOrder defines the display order for help categories.
|
||||
var categoryOrder = []string{
|
||||
"Fun & Games",
|
||||
"Leveling & Stats",
|
||||
"Entertainment",
|
||||
"Lookup & Reference",
|
||||
"LLM & Sentiment",
|
||||
"Personal",
|
||||
"Holidays",
|
||||
"Reactions",
|
||||
"Info",
|
||||
}
|
||||
|
||||
// categoryEmojis maps categories to display emojis.
|
||||
var categoryEmojis = map[string]string{
|
||||
"Fun & Games": "🎲",
|
||||
"Leveling & Stats": "📊",
|
||||
"Entertainment": "🎬",
|
||||
"Lookup & Reference": "📖",
|
||||
"LLM & Sentiment": "🧠",
|
||||
"Personal": "👤",
|
||||
"Holidays": "🎉",
|
||||
"Reactions": "😎",
|
||||
"Info": "ℹ️",
|
||||
"Admin": "🔧",
|
||||
}
|
||||
|
||||
func (p *WelcomePlugin) handleHelp(ctx MessageContext) error {
|
||||
cmds := p.registry.GetCommands()
|
||||
if len(cmds) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No commands available.")
|
||||
}
|
||||
|
||||
// Group by category
|
||||
grouped := make(map[string][]CommandDef)
|
||||
var adminCmds []CommandDef
|
||||
for _, cmd := range cmds {
|
||||
if cmd.AdminOnly {
|
||||
adminCmds = append(adminCmds, cmd)
|
||||
continue
|
||||
}
|
||||
cat := cmd.Category
|
||||
if cat == "" {
|
||||
cat = "Other"
|
||||
}
|
||||
grouped[cat] = append(grouped[cat], cmd)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
helpBotName := os.Getenv("BOT_DISPLAY_NAME")
|
||||
if helpBotName == "" {
|
||||
helpBotName = "GogoBee"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s Commands\n", helpBotName))
|
||||
|
||||
for _, cat := range categoryOrder {
|
||||
cmds, ok := grouped[cat]
|
||||
if !ok || len(cmds) == 0 {
|
||||
continue
|
||||
}
|
||||
emoji := categoryEmojis[cat]
|
||||
sb.WriteString(fmt.Sprintf("\n%s %s\n", emoji, cat))
|
||||
for _, cmd := range cmds {
|
||||
sb.WriteString(fmt.Sprintf(" %s — %s\n", cmd.Usage, cmd.Description))
|
||||
}
|
||||
delete(grouped, cat)
|
||||
}
|
||||
|
||||
// Any uncategorized leftovers
|
||||
for cat, cmds := range grouped {
|
||||
if len(cmds) == 0 {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n%s\n", cat))
|
||||
for _, cmd := range cmds {
|
||||
sb.WriteString(fmt.Sprintf(" %s — %s\n", cmd.Usage, cmd.Description))
|
||||
}
|
||||
}
|
||||
|
||||
if p.IsAdmin(ctx.Sender) && len(adminCmds) > 0 {
|
||||
emoji := categoryEmojis["Admin"]
|
||||
sb.WriteString(fmt.Sprintf("\n%s Admin\n", emoji))
|
||||
for _, cmd := range adminCmds {
|
||||
sb.WriteString(fmt.Sprintf(" %s — %s\n", cmd.Usage, cmd.Description))
|
||||
}
|
||||
}
|
||||
|
||||
// DM the help message
|
||||
if err := p.SendDM(ctx.Sender, sb.String()); err != nil {
|
||||
slog.Error("welcome: send help DM", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to send help DM. Do you have DMs enabled?")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Help sent to your DMs!")
|
||||
}
|
||||
386
internal/plugin/wotd.go
Normal file
386
internal/plugin/wotd.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// wordnikWOTDResponse is the top-level Wordnik Word of the Day response.
|
||||
type wordnikWOTDResponse struct {
|
||||
Word string `json:"word"`
|
||||
Definitions []wordnikWOTDDef `json:"definitions"`
|
||||
Examples []wordnikWOTDEx `json:"examples"`
|
||||
}
|
||||
|
||||
type wordnikWOTDDef struct {
|
||||
Text string `json:"text"`
|
||||
PartOfSpeech string `json:"partOfSpeech"`
|
||||
}
|
||||
|
||||
type wordnikWOTDEx struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// WOTDPlugin provides a Word of the Day feature using the Wordnik API.
|
||||
type WOTDPlugin struct {
|
||||
Base
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewWOTDPlugin creates a new WOTDPlugin.
|
||||
func NewWOTDPlugin(client *mautrix.Client) *WOTDPlugin {
|
||||
return &WOTDPlugin{
|
||||
Base: NewBase(client),
|
||||
apiKey: os.Getenv("WORDNIK_API_KEY"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WOTDPlugin) Name() string { return "wotd" }
|
||||
|
||||
func (p *WOTDPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "wotd", Description: "Show today's Word of the Day", Usage: "!wotd", Category: "Lookup & Reference"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WOTDPlugin) Init() error { return nil }
|
||||
|
||||
func (p *WOTDPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *WOTDPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "wotd") {
|
||||
return p.handleWOTD(ctx)
|
||||
}
|
||||
|
||||
// Passive: track WOTD usage in messages
|
||||
if !ctx.IsCommand {
|
||||
p.trackUsage(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prefetch fetches today's Word of the Day from Wordnik and stores it in the database.
|
||||
func (p *WOTDPlugin) Prefetch() error {
|
||||
if p.apiKey == "" {
|
||||
slog.Warn("wotd: WORDNIK_API_KEY not set, skipping prefetch")
|
||||
return nil
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
// Check if already fetched
|
||||
d := db.Get()
|
||||
var exists int
|
||||
err := d.QueryRow(`SELECT 1 FROM wotd_log WHERE date = ?`, today).Scan(&exists)
|
||||
if err == nil {
|
||||
slog.Info("wotd: already fetched for today", "date", today)
|
||||
return nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=%s", p.apiKey)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wotd: create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wotd: fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("wotd: API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var wotd wordnikWOTDResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&wotd); err != nil {
|
||||
return fmt.Errorf("wotd: decode response: %w", err)
|
||||
}
|
||||
|
||||
definition := ""
|
||||
partOfSpeech := ""
|
||||
if len(wotd.Definitions) > 0 {
|
||||
definition = wotd.Definitions[0].Text
|
||||
partOfSpeech = wotd.Definitions[0].PartOfSpeech
|
||||
}
|
||||
|
||||
example := ""
|
||||
if len(wotd.Examples) > 0 {
|
||||
example = wotd.Examples[0].Text
|
||||
}
|
||||
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO wotd_log (date, word, definition, part_of_speech, example, posted)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(date) DO NOTHING`,
|
||||
today, wotd.Word, definition, partOfSpeech, example,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wotd: store: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("wotd: prefetched", "date", today, "word", wotd.Word)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostWOTD posts today's Word of the Day to the given room and marks it as posted.
|
||||
func (p *WOTDPlugin) PostWOTD(roomID id.RoomID) error {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
var word, definition, partOfSpeech, example string
|
||||
var posted int
|
||||
err := d.QueryRow(
|
||||
`SELECT word, definition, part_of_speech, example, posted FROM wotd_log WHERE date = ?`, today,
|
||||
).Scan(&word, &definition, &partOfSpeech, &example, &posted)
|
||||
if err == sql.ErrNoRows {
|
||||
slog.Warn("wotd: no entry for today, attempting prefetch", "date", today)
|
||||
if err := p.Prefetch(); err != nil {
|
||||
return fmt.Errorf("wotd: prefetch failed: %w", err)
|
||||
}
|
||||
err = d.QueryRow(
|
||||
`SELECT word, definition, part_of_speech, example, posted FROM wotd_log WHERE date = ?`, today,
|
||||
).Scan(&word, &definition, &partOfSpeech, &example, &posted)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wotd: still no entry after prefetch: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("wotd: query: %w", err)
|
||||
}
|
||||
|
||||
if posted == 1 {
|
||||
slog.Info("wotd: already posted today", "date", today)
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := p.formatWOTD(word, definition, partOfSpeech, example)
|
||||
if err := p.SendMessage(roomID, msg); err != nil {
|
||||
return fmt.Errorf("wotd: send message: %w", err)
|
||||
}
|
||||
|
||||
_, err = d.Exec(`UPDATE wotd_log SET posted = 1 WHERE date = ?`, today)
|
||||
if err != nil {
|
||||
slog.Error("wotd: mark posted", "err", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WOTDPlugin) handleWOTD(ctx MessageContext) error {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
var word, definition, partOfSpeech, example string
|
||||
err := d.QueryRow(
|
||||
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`, today,
|
||||
).Scan(&word, &definition, &partOfSpeech, &example)
|
||||
if err == sql.ErrNoRows {
|
||||
// Prefetch on demand
|
||||
if pfErr := p.Prefetch(); pfErr != nil {
|
||||
slog.Error("wotd: on-demand prefetch failed", "err", pfErr)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch Word of the Day. Try again later.")
|
||||
}
|
||||
err = d.QueryRow(
|
||||
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`, today,
|
||||
).Scan(&word, &definition, &partOfSpeech, &example)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("wotd: query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch Word of the Day.")
|
||||
}
|
||||
|
||||
msg := p.formatWOTD(word, definition, partOfSpeech, example)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, example string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Word of the Day: %s", word))
|
||||
|
||||
if partOfSpeech != "" {
|
||||
sb.WriteString(fmt.Sprintf(" (%s)", partOfSpeech))
|
||||
}
|
||||
|
||||
if definition != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n\nDefinition: %s", definition))
|
||||
}
|
||||
|
||||
if example != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n\nExample: \"%s\"", example))
|
||||
}
|
||||
|
||||
sb.WriteString("\n\nUse this word in a message today to earn 25 XP!")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// trackUsage checks if the user used the WOTD in their message and rewards them.
|
||||
func (p *WOTDPlugin) trackUsage(ctx MessageContext) {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
// Get today's word
|
||||
var word string
|
||||
err := d.QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&word)
|
||||
if err != nil {
|
||||
return // No word today or error; silently skip
|
||||
}
|
||||
|
||||
// Check if the message contains the word (case-insensitive)
|
||||
if !strings.Contains(strings.ToLower(ctx.Body), strings.ToLower(word)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Atomically insert/increment usage and try to claim reward in one step
|
||||
// This avoids the TOCTOU race where two messages could both grant XP
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO wotd_usage (user_id, date, count, rewarded) VALUES (?, ?, 1, 0)
|
||||
ON CONFLICT(user_id, date) DO UPDATE SET count = count + 1`,
|
||||
string(ctx.Sender), today,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("wotd: track usage", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already rewarded before doing LLM verification
|
||||
var rewarded int
|
||||
err = d.QueryRow(
|
||||
`SELECT rewarded FROM wotd_usage WHERE user_id = ? AND date = ?`,
|
||||
string(ctx.Sender), today,
|
||||
).Scan(&rewarded)
|
||||
if err != nil || rewarded == 1 {
|
||||
return // Already rewarded or error
|
||||
}
|
||||
|
||||
// Verify correct usage with LLM
|
||||
if !p.verifyUsage(word, ctx.Body) {
|
||||
slog.Debug("wotd: LLM rejected usage", "user", ctx.Sender, "word", word)
|
||||
return
|
||||
}
|
||||
|
||||
// Atomically claim the reward: only update if not yet rewarded
|
||||
res, err := d.Exec(
|
||||
`UPDATE wotd_usage SET rewarded = 1 WHERE user_id = ? AND date = ? AND rewarded = 0`,
|
||||
string(ctx.Sender), today,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("wotd: claim reward", "err", err)
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
return // Already rewarded
|
||||
}
|
||||
|
||||
// Grant 25 XP via direct SQL
|
||||
p.grantWOTDXP(ctx.Sender, 25)
|
||||
|
||||
if err := p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Nice! You used today's word \"%s\" correctly and earned 25 XP!", word)); err != nil {
|
||||
slog.Error("wotd: send reward message", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyUsage asks the LLM whether the word was used correctly in context.
|
||||
// Returns false if LLM is not configured or on any error.
|
||||
func (p *WOTDPlugin) verifyUsage(word, message string) bool {
|
||||
host := os.Getenv("OLLAMA_HOST")
|
||||
model := os.Getenv("OLLAMA_MODEL")
|
||||
if host == "" || model == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`A user sent this chat message: "%s"
|
||||
|
||||
The Word of the Day is "%s". Was this word used correctly and meaningfully in the message? The user must demonstrate understanding of the word by using it naturally in a sentence — not just mentioning it, quoting it, or saying "the word is X".
|
||||
|
||||
Respond with ONLY "yes" or "no".`, message, word)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
||||
slog.Debug("wotd: sending LLM verification request", "url", apiURL, "word", word)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
slog.Error("wotd: LLM verify request failed", "err", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if json.Unmarshal(body, &result) != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
answer := strings.ToLower(strings.TrimSpace(result.Response))
|
||||
accepted := strings.HasPrefix(answer, "yes")
|
||||
slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", accepted)
|
||||
return accepted
|
||||
}
|
||||
|
||||
// grantWOTDXP inserts XP directly via SQL to avoid cross-plugin dependency.
|
||||
func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) {
|
||||
d := db.Get()
|
||||
|
||||
// Ensure user exists
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO users (user_id, xp, level, last_xp_at) VALUES (?, 0, 0, 0)
|
||||
ON CONFLICT(user_id) DO NOTHING`, string(userID))
|
||||
if err != nil {
|
||||
slog.Error("wotd: ensure user", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update XP
|
||||
_, err = d.Exec(
|
||||
`UPDATE users SET xp = xp + ?, last_xp_at = ? WHERE user_id = ?`,
|
||||
amount, time.Now().UTC().Unix(), string(userID))
|
||||
if err != nil {
|
||||
slog.Error("wotd: grant xp", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Log XP grant
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||
string(userID), amount, "wotd_usage")
|
||||
if err != nil {
|
||||
slog.Error("wotd: log xp", "err", err)
|
||||
}
|
||||
}
|
||||
257
internal/plugin/xp.go
Normal file
257
internal/plugin/xp.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/util"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// levelUpMessages are Twinbee/Parodius themed congratulations.
|
||||
var levelUpMessages = []string{
|
||||
"Power-up collected! %s just reached Level %d! 🔔",
|
||||
"Twinbee's bell is ringing! %s ascended to Level %d! 🛎️",
|
||||
"Parodius would be proud — %s hit Level %d! 🐙",
|
||||
"Stage clear! %s leveled up to Level %d! ⭐",
|
||||
"Winbee sends sparkles — %s is now Level %d! ✨",
|
||||
"Bonus round complete! %s reached Level %d! 🎰",
|
||||
"GwinBee detected a power surge — %s is Level %d! ⚡",
|
||||
"The Vic Viper salutes %s for reaching Level %d! 🚀",
|
||||
"Takosuke is dancing! %s just became Level %d! 🎶",
|
||||
"A shower of bells for %s — Level %d unlocked! 🔔🔔🔔",
|
||||
"Pastel confirms: %s has broken through to Level %d! 🌈",
|
||||
"The Shooting Star squadron welcomes %s to Level %d! 💫",
|
||||
}
|
||||
|
||||
// XPPlugin awards XP for messages and tracks levels.
|
||||
type XPPlugin struct {
|
||||
Base
|
||||
mu sync.Mutex
|
||||
cooldowns map[id.UserID]time.Time
|
||||
}
|
||||
|
||||
// NewXPPlugin creates a new XP plugin.
|
||||
func NewXPPlugin(client *mautrix.Client) *XPPlugin {
|
||||
return &XPPlugin{
|
||||
Base: NewBase(client),
|
||||
cooldowns: make(map[id.UserID]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *XPPlugin) Name() string { return "xp" }
|
||||
|
||||
func (p *XPPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "rank", Description: "Show your level, XP, and progress", Usage: "!rank [@user]", Category: "Leveling & Stats"},
|
||||
{Name: "leaderboard", Description: "Show top 10 users by XP", Usage: "!leaderboard", Category: "Leveling & Stats"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *XPPlugin) Init() error { return nil }
|
||||
|
||||
func (p *XPPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *XPPlugin) OnMessage(ctx MessageContext) error {
|
||||
// Handle commands
|
||||
if p.IsCommand(ctx.Body, "rank") {
|
||||
return p.handleRank(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "leaderboard") {
|
||||
return p.handleLeaderboard(ctx)
|
||||
}
|
||||
|
||||
// Skip XP for commands
|
||||
if ctx.IsCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Passive XP with cooldown
|
||||
p.mu.Lock()
|
||||
last, ok := p.cooldowns[ctx.Sender]
|
||||
now := time.Now().UTC()
|
||||
if ok && now.Sub(last) < 30*time.Second {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
p.cooldowns[ctx.Sender] = now
|
||||
// Periodically clean up expired cooldowns to prevent unbounded growth
|
||||
if len(p.cooldowns) > 1000 {
|
||||
for uid, t := range p.cooldowns {
|
||||
if now.Sub(t) > time.Minute {
|
||||
delete(p.cooldowns, uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
_, leveledUp, newLevel := p.grantXPInternal(ctx.Sender, 10, "message")
|
||||
if leveledUp {
|
||||
msg := levelUpMessages[rand.Intn(len(levelUpMessages))]
|
||||
announcement := fmt.Sprintf(msg, string(ctx.Sender), newLevel)
|
||||
if err := p.SendMessage(ctx.RoomID, announcement); err != nil {
|
||||
slog.Error("failed to send level-up announcement", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GrantXP awards XP to a user from an external plugin and returns (newXP, leveledUp, newLevel).
|
||||
func (p *XPPlugin) GrantXP(userID id.UserID, amount int, reason string) (int, bool, int) {
|
||||
return p.grantXPInternal(userID, amount, reason)
|
||||
}
|
||||
|
||||
func (p *XPPlugin) grantXPInternal(userID id.UserID, amount int, reason string) (int, bool, int) {
|
||||
d := db.Get()
|
||||
now := time.Now().UTC().Unix()
|
||||
|
||||
// Ensure user row exists
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO users (user_id, xp, level, last_xp_at) VALUES (?, 0, 0, 0)
|
||||
ON CONFLICT(user_id) DO NOTHING`, string(userID))
|
||||
if err != nil {
|
||||
slog.Error("xp: ensure user", "err", err)
|
||||
return 0, false, 0
|
||||
}
|
||||
|
||||
// Get current state
|
||||
var oldXP, oldLevel int
|
||||
err = d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, string(userID)).Scan(&oldXP, &oldLevel)
|
||||
if err != nil {
|
||||
slog.Error("xp: read user", "err", err)
|
||||
return 0, false, 0
|
||||
}
|
||||
|
||||
newXP := oldXP + amount
|
||||
newLevel := util.LevelFromXP(newXP)
|
||||
leveledUp := newLevel > oldLevel
|
||||
|
||||
_, err = d.Exec(
|
||||
`UPDATE users SET xp = ?, level = ?, last_xp_at = ? WHERE user_id = ?`,
|
||||
newXP, newLevel, now, string(userID))
|
||||
if err != nil {
|
||||
slog.Error("xp: update user", "err", err)
|
||||
return oldXP, false, oldLevel
|
||||
}
|
||||
|
||||
// Log the XP grant
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||
string(userID), amount, reason)
|
||||
if err != nil {
|
||||
slog.Error("xp: log grant", "err", err)
|
||||
}
|
||||
|
||||
return newXP, leveledUp, newLevel
|
||||
}
|
||||
|
||||
func (p *XPPlugin) handleRank(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "rank")
|
||||
if args != "" {
|
||||
// Trim optional @ prefix and whitespace
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||
if cleaned != "" {
|
||||
target = id.UserID(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var xp, level int
|
||||
err := d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, string(target)).Scan(&xp, &level)
|
||||
if err == sql.ErrNoRows {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s hasn't earned any XP yet.", string(target)))
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("xp: rank query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up rank.")
|
||||
}
|
||||
|
||||
nextLevel := level + 1
|
||||
xpForNext := util.XPForLevel(nextLevel)
|
||||
xpForCurrent := util.XPForLevel(level)
|
||||
progress := xp - xpForCurrent
|
||||
needed := xpForNext - xpForCurrent
|
||||
bar := util.ProgressBar(progress, needed, 20)
|
||||
|
||||
// Get rank position
|
||||
var rank int
|
||||
err = d.QueryRow(`SELECT COUNT(*) + 1 FROM users WHERE xp > ?`, xp).Scan(&rank)
|
||||
if err != nil {
|
||||
rank = 0
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"📊 %s — Level %d (Rank #%s)\nXP: %s / %s\n%s",
|
||||
string(target), level, formatNumber(rank),
|
||||
formatNumber(xp), formatNumber(xpForNext), bar,
|
||||
)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *XPPlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT user_id, xp, level FROM users ORDER BY xp DESC LIMIT 10`)
|
||||
if err != nil {
|
||||
slog.Error("xp: leaderboard query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🏆 XP Leaderboard — Top 10\n\n")
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var xp, level int
|
||||
if err := rows.Scan(&userID, &xp, &level); err != nil {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s — Level %d (%s XP)\n", prefix, userID, level, formatNumber(xp)))
|
||||
i++
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No XP data yet.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// formatNumber adds commas to an integer for display.
|
||||
func formatNumber(n int) string {
|
||||
if n < 0 {
|
||||
return "-" + formatNumber(-n)
|
||||
}
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var result strings.Builder
|
||||
remainder := len(s) % 3
|
||||
if remainder > 0 {
|
||||
result.WriteString(s[:remainder])
|
||||
}
|
||||
for i := remainder; i < len(s); i += 3 {
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(',')
|
||||
}
|
||||
result.WriteString(s[i : i+3])
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
86
internal/util/auth.go
Normal file
86
internal/util/auth.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginResponse holds the Matrix login response fields we care about.
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
DeviceID string `json:"device_id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// LoginWithPassword performs a Matrix password login.
|
||||
func LoginWithPassword(homeserver, user, password, deviceDisplayName string) (*LoginResponse, error) {
|
||||
url := homeserver + "/_matrix/client/v3/login"
|
||||
|
||||
body := map[string]any{
|
||||
"type": "m.login.password",
|
||||
"identifier": map[string]string{
|
||||
"type": "m.id.user",
|
||||
"user": user,
|
||||
},
|
||||
"password": password,
|
||||
"initial_device_display_name": deviceDisplayName,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal login body: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Post(url, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result LoginResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse login response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// IsTokenValid checks if an access token is still valid via /account/whoami.
|
||||
func IsTokenValid(homeserver, accessToken string) (bool, string) {
|
||||
url := homeserver + "/_matrix/client/v3/account/whoami"
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
var result struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return true, result.UserID
|
||||
}
|
||||
24
internal/util/logger.go
Normal file
24
internal/util/logger.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
// InitLogger sets up a structured slog logger writing to stderr.
|
||||
func InitLogger(level string) {
|
||||
var lvl slog.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
|
||||
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})
|
||||
slog.SetDefault(slog.New(handler))
|
||||
}
|
||||
160
internal/util/parser.go
Normal file
160
internal/util/parser.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// MessageStats holds parsed message metrics.
|
||||
type MessageStats struct {
|
||||
Words int
|
||||
Chars int
|
||||
Links int
|
||||
Images int
|
||||
Questions int
|
||||
Exclamations int
|
||||
Emojis int
|
||||
}
|
||||
|
||||
var (
|
||||
linkRe = regexp.MustCompile(`https?://\S+`)
|
||||
imageRe = regexp.MustCompile(`\.(png|jpg|jpeg|gif|webp|svg|bmp)(\?[^\s]*)?$`)
|
||||
emojiRe = regexp.MustCompile(`[\x{1F600}-\x{1F64F}]|[\x{1F300}-\x{1F5FF}]|[\x{1F680}-\x{1F6FF}]|[\x{1F1E0}-\x{1F1FF}]|[\x{2600}-\x{26FF}]|[\x{2700}-\x{27BF}]`)
|
||||
)
|
||||
|
||||
// ParseMessage extracts stats from a chat message body.
|
||||
func ParseMessage(body string) MessageStats {
|
||||
words := strings.Fields(body)
|
||||
|
||||
links := linkRe.FindAllString(body, -1)
|
||||
images := 0
|
||||
for _, l := range links {
|
||||
if imageRe.MatchString(strings.ToLower(l)) {
|
||||
images++
|
||||
}
|
||||
}
|
||||
|
||||
questions := strings.Count(body, "?")
|
||||
exclamations := strings.Count(body, "!")
|
||||
emojis := len(emojiRe.FindAllString(body, -1))
|
||||
|
||||
return MessageStats{
|
||||
Words: len(words),
|
||||
Chars: len([]rune(body)),
|
||||
Links: len(links),
|
||||
Images: images,
|
||||
Questions: questions,
|
||||
Exclamations: exclamations,
|
||||
Emojis: emojis,
|
||||
}
|
||||
}
|
||||
|
||||
// XPForLevel returns the cumulative XP needed to reach level n.
|
||||
// Uses quadratic curve: level² × 100.
|
||||
func XPForLevel(level int) int {
|
||||
return level * level * 100
|
||||
}
|
||||
|
||||
// LevelFromXP returns the current level for a given XP total.
|
||||
// Inverse of level² × 100: level = floor(sqrt(xp / 100)).
|
||||
func LevelFromXP(xp int) int {
|
||||
if xp <= 0 {
|
||||
return 0
|
||||
}
|
||||
level := 0
|
||||
for (level+1)*(level+1)*100 <= xp {
|
||||
level++
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// ProgressBar returns a text progress bar like [#####-----] 50%.
|
||||
func ProgressBar(current, max, width int) string {
|
||||
if max <= 0 {
|
||||
return strings.Repeat("-", width)
|
||||
}
|
||||
ratio := float64(current) / float64(max)
|
||||
if ratio > 1 {
|
||||
ratio = 1
|
||||
}
|
||||
filled := int(math.Round(ratio * float64(width)))
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
empty := width - filled
|
||||
pct := int(ratio * 100)
|
||||
|
||||
return "[" + strings.Repeat("#", filled) + strings.Repeat("-", empty) + "] " + strconv.Itoa(pct) + "%"
|
||||
}
|
||||
|
||||
// Archetype definitions matching the TS version.
|
||||
type Archetype struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
var archetypes = []struct {
|
||||
name string
|
||||
desc string
|
||||
check func(s MessageStats, totalMessages int) bool
|
||||
}{
|
||||
{"Chatterbox", "You talk a LOT", func(s MessageStats, t int) bool { return t > 500 && s.Words > 0 }},
|
||||
{"Novelist", "Long-form writer", func(s MessageStats, t int) bool { return s.Words > 0 && s.Chars/max1(s.Words) > 8 }},
|
||||
{"Inquisitor", "Always asking questions", func(s MessageStats, t int) bool { return s.Questions > t/5 && s.Questions > 10 }},
|
||||
{"Linkmaster", "Shares lots of links", func(s MessageStats, t int) bool { return s.Links > t/10 && s.Links > 5 }},
|
||||
{"Shutterbug", "Posts lots of images", func(s MessageStats, t int) bool { return s.Images > t/10 && s.Images > 5 }},
|
||||
{"Enthusiast", "Lots of exclamation marks!", func(s MessageStats, t int) bool { return s.Exclamations > t/4 && s.Exclamations > 10 }},
|
||||
{"Regular", "A steady community member", func(_ MessageStats, _ int) bool { return true }},
|
||||
}
|
||||
|
||||
// DeriveArchetype picks the best-fitting archetype based on aggregate stats.
|
||||
func DeriveArchetype(stats MessageStats, totalMessages int) Archetype {
|
||||
for _, a := range archetypes {
|
||||
if a.check(stats, totalMessages) {
|
||||
return Archetype{Name: a.name, Description: a.desc}
|
||||
}
|
||||
}
|
||||
return Archetype{Name: "Regular", Description: "A steady community member"}
|
||||
}
|
||||
|
||||
func max1(n int) int {
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// IsCommand checks if body starts with prefix+command (case-insensitive).
|
||||
func IsCommand(body, prefix, command string) bool {
|
||||
cmd := prefix + command
|
||||
lower := strings.ToLower(strings.TrimSpace(body))
|
||||
if lower == cmd {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(lower, cmd+" ")
|
||||
}
|
||||
|
||||
// GetArgs returns everything after the command prefix.
|
||||
func GetArgs(body, prefix, command string) string {
|
||||
cmd := prefix + command
|
||||
trimmed := strings.TrimSpace(body)
|
||||
lower := strings.ToLower(trimmed)
|
||||
if !strings.HasPrefix(lower, cmd) {
|
||||
return ""
|
||||
}
|
||||
rest := trimmed[len(cmd):]
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
// HasNonASCII checks if string contains non-ASCII characters.
|
||||
func HasNonASCII(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > unicode.MaxASCII {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user