Files
gogobee/internal/plugin/lookup.go
prosolis 78cbd895f4 Add dictionary commands, antonym/etymology integrations, and audit fixes
New DictionaryPlugin with !antonym, !pronounce, !etymology, !difficulty,
and !rhyme commands. Integrate antonyms into !define (concurrent 500ms
fetch), difficulty tier into Hangman headers, and etymology into WOTD.
Fix rhyme limit bug (request 50, display 7), variable shadowing, and
add spec-required emoji prefixes to all dictionary command outputs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 06:45:25 -07:00

430 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))
}
}
}
// Concurrently fetch antonyms from DreamDict (500ms timeout).
if p.dict != nil {
type antResult struct {
ants []string
}
ch := make(chan antResult, 1)
go func() {
ants, err := p.dict.Antonyms(strings.ToLower(word), "en")
if err != nil {
ch <- antResult{}
return
}
ch <- antResult{ants: ants}
}()
timer := time.NewTimer(500 * time.Millisecond)
select {
case r := <-ch:
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:
// Timeout — omit antonyms silently.
}
}
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"))
}