mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Security & economy: - Credit()/Debit() reject non-positive amounts (closes infinite-money exploit) - Lottery ticket purchase uses in-transaction count check (closes TOCTOU race) - Arena bail channel ownership prevents double-close panic - Entry.ID validated before exec.Command in fetch-esteemed - Internal errors no longer leaked to users (forex, esteemed) Robustness: - safeGo() panic recovery added to 17 goroutine launch sites across 14 plugins - db.Close() added and called on shutdown - Miniflux mutex pattern fixed (snapshot-under-lock) - Silently ignored DB/JSON errors now logged (lottery_db) Performance: - Added indexes: idx_arena_runs_user(user_id, status), idx_euro_bal_user(user_id) UX: - Empty !buy args shows usage instead of "No item matching ''" - "Failed to load character" now suggests !adventure Test coverage: - internal/util/parser_test.go (19 tests: XP, levels, progress bar, commands, parsing) - internal/crypto/crypto_test.go (12 tests: encrypt/decrypt, HMAC, ParseKey) - internal/plugin/helpers_test.go (30 tests: formatNumber, calc, lottery, chat perks) - internal/plugin/audit_fixes_test.go (25 tests: safeGo, bail channels, error leaks, overflow) - internal/db/db_test.go (4 tests: Init, Close, schema indexes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
503 lines
14 KiB
Go
503 lines
14 KiB
Go
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
|
||
}
|
||
safeGo("lookup-handler", 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
|
||
safeGo("lookup-antonyms", 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"))
|
||
}
|