mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
548 lines
14 KiB
Go
548 lines
14 KiB
Go
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
|
|
}
|