mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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>
430 lines
12 KiB
Go
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"))
|
|
}
|