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 ", Category: "Lookup & Reference"}, {Name: "define", Description: "Look up a word definition", Usage: "!define ", Category: "Lookup & Reference"}, {Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban ", Category: "Lookup & Reference"}, {Name: "translate", Description: "Look up word translations (en/fr/pt-PT)", Usage: "!translate [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 ") } // 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 { word := strings.TrimSpace(p.GetArgs(ctx.Body, "define")) if word == "" { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define ") } // Check 24h cache cacheKey := "define:" + strings.ToLower(word) if cached := db.CacheGet(cacheKey, 86400); cached != "" { return p.SendReply(ctx.RoomID, ctx.EventID, cached) } 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)) } } } 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 ") } 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 [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")) }