Files
gogobee/internal/plugin/lookup.go
prosolis ad6e652755 Add rival duels, babysitting service, multilingual !define, economy tuning
Rival System:
- RPS-based duels between combat level 5+ players (best of 3, ties re-prompt)
- Stakes scale with level, split 50/50 between winner and community pot
- 3-4 day randomized challenge interval, 7-day same-pair cooldown
- 24h response window with auto-forfeit
- Full flavor text pools, character sheet integration, !adventure rivals

Babysitting Service:
- Weekly/monthly auto-play service targeting weakest skill
- Babysitter rerolls death, claims items, credits gold and XP
- Rivals automatically declined during service
- End-of-service summary with diaper report

Revive fixes:
- On-demand revive in handleMenu, parseAndResolveChoice, handleStatus
- Morning DM respawn check now runs before babysit interception
- Players no longer stuck dead after DeadUntil expires mid-day

Other changes:
- Multilingual !define searches all DreamDict languages by default
- TwinBee empty haul now shows "jackshit" instead of blank
- Wordle solve payouts increased (e.g. 1-guess: €100 -> €500)
- Per-message euro payouts doubled across all tiers
- Audit fixes: user locks on RPS/babysit handlers, TOCTOU on gold
  transfers, dead code removal, deprecated strings.Title replaced,
  error logging on silent failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 20:11:51 -07:00

503 lines
14 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/dreamclient"
"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
dict *dreamclient.Client
}
// NewLookupPlugin creates a new lookup plugin.
func NewLookupPlugin(client *mautrix.Client, rateLimiter *RateLimitsPlugin, dict *dreamclient.Client) *LookupPlugin {
return &LookupPlugin{
Base: NewBase(client),
rateLimiter: rateLimiter,
dict: dict,
}
}
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> [lang]", Category: "Lookup & Reference"},
{Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban <term>", Category: "Lookup & Reference"},
{Name: "translate", Description: "Look up word translations (en/fr/pt-PT)", Usage: "!translate <word> [lang]", 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 {
var handler func(MessageContext) error
switch {
case p.IsCommand(ctx.Body, "wiki"):
handler = p.handleWiki
case p.IsCommand(ctx.Body, "define"):
handler = p.handleDefine
case p.IsCommand(ctx.Body, "urban"):
handler = p.handleUrban
case p.IsCommand(ctx.Body, "translate"):
handler = p.handleTranslate
default:
return nil
}
go func() {
if err := handler(ctx); err != nil {
slog.Error("lookup: handler error", "err", err)
}
}()
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>")
}
// Check 24h cache
cacheKey := "wiki:" + strings.ToLower(topic)
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
}
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)
db.CacheSet(cacheKey, msg)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word> [lang]")
}
parts := strings.Fields(args)
word := parts[0]
var filterLang string
if len(parts) >= 2 {
filterLang = normaliseLangExt(parts[len(parts)-1])
if filterLang != "" && len(parts) > 2 {
word = strings.Join(parts[:len(parts)-1], " ")
} else if filterLang == "" {
// Last token isn't a lang — treat the whole thing as the word.
word = args
}
}
wordLower := strings.ToLower(word)
// Check 24h cache.
cacheKey := "define:" + wordLower
if filterLang != "" {
cacheKey += ":" + filterLang
}
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
}
var sb strings.Builder
foundDreamDict := false
// Try DreamDict across languages.
if p.dict != nil {
langs := []string{"en", "fr", "pt-PT", "zh"}
if filterLang != "" {
langs = []string{filterLang}
}
// Fire antonym fetch concurrently (for whichever lang the word is found in).
type antResult struct {
ants []string
}
antCh := make(chan antResult, 1)
antStarted := false
for _, lang := range langs {
defs, err := p.dict.Define(wordLower, lang)
if err != nil {
slog.Error("lookup: dreamdict define", "lang", lang, "err", err)
continue
}
if len(defs) == 0 {
continue
}
if !foundDreamDict {
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n", word))
foundDreamDict = true
}
langName := langDisplayName(lang)
sb.WriteString(fmt.Sprintf("\n🏷 %s\n", langName))
count := 0
for _, def := range defs {
if count >= 4 {
break
}
if def.POS != "" {
sb.WriteString(fmt.Sprintf(" (%s) %s\n", def.POS, def.Gloss))
} else {
sb.WriteString(fmt.Sprintf(" %s\n", def.Gloss))
}
count++
}
// Start antonym fetch for the first language we find a match in.
if !antStarted {
antStarted = true
antLang := lang
go func() {
ants, err := p.dict.Antonyms(wordLower, antLang)
if err != nil {
antCh <- antResult{}
return
}
antCh <- antResult{ants: ants}
}()
}
}
// Collect antonyms (500ms timeout).
if antStarted {
timer := time.NewTimer(500 * time.Millisecond)
select {
case r := <-antCh:
timer.Stop()
if len(r.ants) > 0 {
display := r.ants
if len(display) > 3 {
display = display[:3]
}
sb.WriteString(fmt.Sprintf("\nAntonyms: %s\n", strings.Join(display, ", ")))
}
case <-timer.C:
}
}
}
// Fall back to free dictionary API for English if DreamDict had no results.
if !foundDreamDict && (filterLang == "" || filterLang == "en") {
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]
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
for i, meaning := range entry.Meanings {
if i >= 3 {
break
}
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
for j, def := range meaning.Definitions {
if j >= 2 {
break
}
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
if def.Example != "" {
sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example))
}
}
}
} else if !foundDreamDict {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("No definition found for \"%s\" in %s.", word, langDisplayName(filterLang)))
}
msg := sb.String()
db.CacheSet(cacheKey, msg)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
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
db.Exec("lookup: urban cache",
`INSERT INTO urban_cache (term, data, cached_at) VALUES (?, ?, ?)
ON CONFLICT(term) DO UPDATE SET data = ?, cached_at = ?`,
termLower, msg, now, msg, now,
)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// dreamdictLangs are the supported DreamDict languages in lookup order.
var dreamdictLangs = []string{"en", "fr", "pt-PT"}
// dreamdictLangNames maps language tags to display names.
var dreamdictLangNames = map[string]string{
"en": "English",
"fr": "French",
"pt-PT": "Portuguese",
}
// normaliseLang normalises a language tag to the canonical DreamDict form.
func normaliseLang(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
switch s {
case "en":
return "en"
case "fr":
return "fr"
case "pt-pt", "pt":
return "pt-PT"
default:
return ""
}
}
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 <word> [lang]\nSupported languages: en, fr, pt-PT")
}
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.")
}
parts := strings.Fields(args)
word := parts[0]
var sourceLang string
if len(parts) >= 2 {
sourceLang = normaliseLang(parts[1])
if sourceLang == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("❌ Unknown language \"%s\". Supported: en, fr, pt-PT", parts[1]))
}
}
// If lang specified, validate word exists in that language
if sourceLang != "" {
valid, err := p.dict.IsValidWord(word, sourceLang)
if err != nil {
slog.Error("lookup: translate valid check", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.")
}
if !valid {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("❌ \"%s\" not found in %s.", word, dreamdictLangNames[sourceLang]))
}
} else {
// Auto-detect: try each language in order
for _, lang := range dreamdictLangs {
valid, err := p.dict.IsValidWord(word, lang)
if err != nil {
slog.Error("lookup: translate auto-detect", "lang", lang, "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.")
}
if valid {
sourceLang = lang
break
}
}
if sourceLang == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("❌ \"%s\" not found in any supported language.", word))
}
}
// Translate to other languages
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🔤 %s (%s)\n", word, sourceLang))
for _, targetLang := range dreamdictLangs {
if targetLang == sourceLang {
continue
}
translations, err := p.dict.Translate(word, sourceLang, targetLang)
if err != nil {
slog.Error("lookup: translate call", "from", sourceLang, "to", targetLang, "err", err)
sb.WriteString(fmt.Sprintf(" → %s: (no direct translation)\n", targetLang))
continue
}
if len(translations) == 0 {
sb.WriteString(fmt.Sprintf(" → %s: (no direct translation)\n", targetLang))
continue
}
display := translations
extra := 0
if len(display) > 5 {
extra = len(display) - 5
display = display[:5]
}
line := strings.Join(display, ", ")
if extra > 0 {
line += fmt.Sprintf(" (+%d more)", extra)
}
sb.WriteString(fmt.Sprintf(" → %s: %s\n", targetLang, line))
}
return p.SendReply(ctx.RoomID, ctx.EventID, strings.TrimRight(sb.String(), "\n"))
}