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:
prosolis
2026-03-08 18:25:18 -07:00
parent 07c8674fe0
commit ddb196aaad
91 changed files with 12865 additions and 13909 deletions

204
internal/bot/client.go Normal file
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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")
}

View 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
View 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
View 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
View 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
View 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(&timesInsulted, &timesInsulting); 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
}

View 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
View 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
View 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
View 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&region=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
View 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
View 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())
}

View 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
}

View 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
}

View 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)
}

View 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
View 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
View 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
View 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
View 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(&quote); err != nil {
return nil, err
}
return &quote, 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
View 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
View 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
View 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
View 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
View 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(&quoteText, &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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}