Files
gogobee/internal/plugin/lookup.go
prosolis 1c4098ad1c Add DreamDict integrations: translate, hangman clues, and Portuguese WOTD
- Replace LibreTranslate-based !translate with DreamDict cross-language
  lookup (en/fr/pt-PT) with auto-detect and 5-per-lang cap
- Add multilingual hangman mode (!hangman <lang> [--clue <lang>]) using
  DreamDict RandomWord/Translate with retry logic and race-safe placeholder
- Rewrite WOTD to pick pt-PT words with Portuguese definitions and
  en/fr translations, add !wotd force for moderators
- Move DreamDict client init earlier in main.go for broader availability
- Wire dictClient into LookupPlugin, HangmanPlugin, and WOTDPlugin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:03:07 -07:00

399 lines
12 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>", 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 {
word := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
if word == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word>")
}
// 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 <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"))
}