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") { if !isGamesRoom(ctx.RoomID) { return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") } 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 safeGo("trivia-expire", 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 }