From 78cbd895f41bd7cdacf91f8025dd7e05d3739ac4 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 2 Apr 2026 06:45:25 -0700 Subject: [PATCH] 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 --- internal/dreamclient/client.go | 122 +++++++++++ internal/plugin/dictionary.go | 367 +++++++++++++++++++++++++++++++++ internal/plugin/hangman.go | 12 +- internal/plugin/lookup.go | 31 +++ internal/plugin/wotd.go | 20 ++ main.go | 1 + 6 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 internal/plugin/dictionary.go diff --git a/internal/dreamclient/client.go b/internal/dreamclient/client.go index 21c2cba..a0f02ff 100644 --- a/internal/dreamclient/client.go +++ b/internal/dreamclient/client.go @@ -215,6 +215,128 @@ func (c *Client) FrequencyBatch(words []string, lang string) (map[string]int, er return result.Frequencies, nil } +// Antonyms returns antonyms for a word in a language. +func (c *Client) Antonyms(word, lang string) ([]string, error) { + u := c.baseURL + "/antonyms?" + url.Values{"word": {word}, "lang": {lang}}.Encode() + resp, err := c.httpClient.Get(u) + if err != nil { + return nil, fmt.Errorf("dreamdict: antonyms: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("dreamdict: antonyms: status %d", resp.StatusCode) + } + + var result struct { + Antonyms []string `json:"antonyms"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("dreamdict: antonyms: decode: %w", err) + } + return result.Antonyms, nil +} + +// Pronunciation holds pronunciation data for a word. +type Pronunciation struct { + Format string `json:"format"` + Value string `json:"value"` + Source string `json:"source"` +} + +// Pronunciations returns pronunciation data for a word in a language. +func (c *Client) Pronunciations(word, lang string) ([]Pronunciation, error) { + u := c.baseURL + "/pronunciation?" + url.Values{"word": {word}, "lang": {lang}}.Encode() + resp, err := c.httpClient.Get(u) + if err != nil { + return nil, fmt.Errorf("dreamdict: pronunciation: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("dreamdict: pronunciation: status %d", resp.StatusCode) + } + + var result struct { + Pronunciations []Pronunciation `json:"pronunciations"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("dreamdict: pronunciation: decode: %w", err) + } + return result.Pronunciations, nil +} + +// Etymology returns the etymology text for a word in a language. +func (c *Client) Etymology(word, lang string) (string, error) { + u := c.baseURL + "/etymology?" + url.Values{"word": {word}, "lang": {lang}}.Encode() + resp, err := c.httpClient.Get(u) + if err != nil { + return "", fmt.Errorf("dreamdict: etymology: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("dreamdict: etymology: status %d", resp.StatusCode) + } + + var result struct { + Etymology string `json:"etymology"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("dreamdict: etymology: decode: %w", err) + } + return result.Etymology, nil +} + +// Difficulty returns the difficulty score (0.0-1.0) for a word in a language. +// Returns -1 if the word has no difficulty score. +func (c *Client) Difficulty(word, lang string) (float64, error) { + u := c.baseURL + "/difficulty?" + url.Values{"word": {word}, "lang": {lang}}.Encode() + resp, err := c.httpClient.Get(u) + if err != nil { + return 0, fmt.Errorf("dreamdict: difficulty: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("dreamdict: difficulty: status %d", resp.StatusCode) + } + + var result struct { + Difficulty float64 `json:"difficulty"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("dreamdict: difficulty: decode: %w", err) + } + return result.Difficulty, nil +} + +// Rhymes returns words that rhyme with the given word (English only). +func (c *Client) Rhymes(word string, limit int) ([]string, error) { + params := url.Values{"word": {word}} + if limit > 0 { + params.Set("limit", fmt.Sprintf("%d", limit)) + } + u := c.baseURL + "/rhyme?" + params.Encode() + resp, err := c.httpClient.Get(u) + if err != nil { + return nil, fmt.Errorf("dreamdict: rhyme: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("dreamdict: rhyme: status %d", resp.StatusCode) + } + + var result struct { + Rhymes []string `json:"rhymes"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("dreamdict: rhyme: decode: %w", err) + } + return result.Rhymes, nil +} + // Health checks if the DreamDict service is reachable and returns stats. func (c *Client) Health() (*HealthResponse, error) { resp, err := c.httpClient.Get(c.baseURL + "/health") diff --git a/internal/plugin/dictionary.go b/internal/plugin/dictionary.go new file mode 100644 index 0000000..252dc7d --- /dev/null +++ b/internal/plugin/dictionary.go @@ -0,0 +1,367 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" + + "gogobee/internal/dreamclient" + + "maunium.net/go/mautrix" +) + +// DictionaryPlugin provides extended dictionary commands using DreamDict. +type DictionaryPlugin struct { + Base + dict *dreamclient.Client +} + +func NewDictionaryPlugin(client *mautrix.Client, dict *dreamclient.Client) *DictionaryPlugin { + return &DictionaryPlugin{ + Base: NewBase(client), + dict: dict, + } +} + +func (p *DictionaryPlugin) Name() string { return "dictionary" } + +func (p *DictionaryPlugin) Commands() []CommandDef { + return []CommandDef{ + {Name: "antonym", Description: "Look up antonyms for a word", Usage: "!antonym [lang]", Category: "Lookup & Reference"}, + {Name: "pronounce", Description: "Look up pronunciation of a word", Usage: "!pronounce [lang]", Category: "Lookup & Reference"}, + {Name: "etymology", Description: "Look up the etymology of a word", Usage: "!etymology [lang]", Category: "Lookup & Reference"}, + {Name: "difficulty", Description: "Show difficulty score for a word", Usage: "!difficulty [lang]", Category: "Lookup & Reference"}, + {Name: "rhyme", Description: "Find rhyming words (English)", Usage: "!rhyme ", Category: "Lookup & Reference"}, + } +} + +func (p *DictionaryPlugin) Init() error { return nil } + +func (p *DictionaryPlugin) OnReaction(_ ReactionContext) error { return nil } + +func (p *DictionaryPlugin) OnMessage(ctx MessageContext) error { + var handler func(MessageContext) error + switch { + case p.IsCommand(ctx.Body, "antonym"): + handler = p.handleAntonym + case p.IsCommand(ctx.Body, "pronounce"): + handler = p.handlePronounce + case p.IsCommand(ctx.Body, "etymology"): + handler = p.handleEtymology + case p.IsCommand(ctx.Body, "difficulty"): + handler = p.handleDifficulty + case p.IsCommand(ctx.Body, "rhyme"): + handler = p.handleRhyme + default: + return nil + } + go func() { + if err := handler(ctx); err != nil { + slog.Error("dictionary: handler error", "err", err) + } + }() + return nil +} + +const dictUnavailable = "Dictionary service unavailable. Try again shortly." +const dictSupportedLangs = "en, fr, pt-PT, zh" + +// parseWordLang extracts word and language from command args. Returns empty word if missing. +func parseWordLang(args string) (word, lang string) { + parts := strings.Fields(args) + if len(parts) == 0 { + return "", "" + } + word = strings.ToLower(parts[0]) + lang = "en" + if len(parts) >= 2 { + if l := normaliseLangExt(parts[len(parts)-1]); l != "" { + lang = l + if len(parts) > 2 { + word = strings.ToLower(strings.Join(parts[:len(parts)-1], " ")) + } + } + } + return word, lang +} + +// normaliseLangExt normalises a language tag, including zh. +func normaliseLangExt(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" + case "zh": + return "zh" + default: + return "" + } +} + +func (p *DictionaryPlugin) handleAntonym(ctx MessageContext) error { + args := strings.TrimSpace(p.GetArgs(ctx.Body, "antonym")) + if args == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, + "Usage: `!antonym [lang]`\nSupported languages: "+dictSupportedLangs) + } + + word, lang := parseWordLang(args) + if p.dict == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + + // Check word exists. + valid, err := p.dict.IsValidWord(word, lang) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + if !valid { + langName := langDisplayName(lang) + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("\"**%s**\" not found in %s.", word, langName)) + } + + ants, err := p.dict.Antonyms(word, lang) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + + if len(ants) == 0 { + langName := langDisplayName(lang) + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("🔄 No antonyms found for \"**%s**\" in %s.", word, langName)) + } + + display := ants + more := "" + if len(display) > 6 { + more = fmt.Sprintf(" (+%d more)", len(display)-6) + display = display[:6] + } + + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("🔄 Antonyms of **%s** (%s): %s%s", word, lang, strings.Join(display, ", "), more)) +} + +func (p *DictionaryPlugin) handlePronounce(ctx MessageContext) error { + args := strings.TrimSpace(p.GetArgs(ctx.Body, "pronounce")) + if args == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, + "Usage: `!pronounce [lang]`\nSupported languages: "+dictSupportedLangs) + } + + word, lang := parseWordLang(args) + if p.dict == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + + valid, err := p.dict.IsValidWord(word, lang) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + if !valid { + langName := langDisplayName(lang) + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("\"**%s**\" not found in %s.", word, langName)) + } + + prons, err := p.dict.Pronunciations(word, lang) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + + if len(prons) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("🔊 No pronunciation data found for \"**%s**\" in %s.", word, langDisplayName(lang))) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🔊 **%s** (%s)\n", word, lang)) + + for _, pr := range prons { + label := strings.ToUpper(pr.Format) + if lang == "zh" && pr.Format == "ipa" { + label = "Pinyin" + } + sb.WriteString(fmt.Sprintf(" %s: %s\n", label, pr.Value)) + } + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +func (p *DictionaryPlugin) handleEtymology(ctx MessageContext) error { + args := strings.TrimSpace(p.GetArgs(ctx.Body, "etymology")) + if args == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, + "Usage: `!etymology [lang]`\nSupported languages: "+dictSupportedLangs) + } + + word, lang := parseWordLang(args) + if p.dict == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + + valid, err := p.dict.IsValidWord(word, lang) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + if !valid { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("\"**%s**\" not found in %s.", word, langDisplayName(lang))) + } + + etym, err := p.dict.Etymology(word, lang) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + + if etym == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("📜 No etymology data found for \"**%s**\" in %s.", word, langDisplayName(lang))) + } + + // Truncate at 400 chars for standalone command. + if len(etym) > 400 { + // Try to truncate at sentence boundary. + if idx := strings.LastIndex(etym[:400], "."); idx > 200 { + etym = etym[:idx+1] + "..." + } else { + etym = etym[:400] + "..." + } + } + + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("📜 Etymology of **%s** (%s)\n %s", word, lang, etym)) +} + +func (p *DictionaryPlugin) handleDifficulty(ctx MessageContext) error { + args := strings.TrimSpace(p.GetArgs(ctx.Body, "difficulty")) + if args == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, + "Usage: `!difficulty [lang]`\nSupported languages: "+dictSupportedLangs) + } + + word, lang := parseWordLang(args) + if p.dict == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + + valid, err := p.dict.IsValidWord(word, lang) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + if !valid { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("\"**%s**\" not found in %s.", word, langDisplayName(lang))) + } + + diff, err := p.dict.Difficulty(word, lang) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + if diff < 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("📊 No difficulty score available for \"**%s**\" in %s.", word, langDisplayName(lang))) + } + + tier := difficultyTier(diff) + + // Get frequency for display. + freq, _ := p.dict.Frequency(word, lang) + freqLabel := frequencyLabel(freq) + + wordLen := len([]rune(word)) + + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("📊 **%s** (%s) -- %s (%.2f)\n Frequency: %s | Length: %d", + word, lang, tier, diff, freqLabel, wordLen)) +} + +func (p *DictionaryPlugin) handleRhyme(ctx MessageContext) error { + args := strings.TrimSpace(p.GetArgs(ctx.Body, "rhyme")) + if args == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, + "Usage: `!rhyme `\nNote: rhyme matching is English only.") + } + + parts := strings.Fields(args) + word := strings.ToLower(parts[0]) + showAll := false + for _, arg := range parts[1:] { + if arg == "--all" { + showAll = true + } + } + + if p.dict == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + + rhymes, err := p.dict.Rhymes(word, 50) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable) + } + + if len(rhymes) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("🎵 No rhymes found for \"**%s**\". (This is not surprising.)", word)) + } + + display := rhymes + more := "" + if !showAll && len(display) > 7 { + more = fmt.Sprintf(" (+%d more)\n Use `!rhyme %s --all` to see all results", len(display)-7, word) + display = display[:7] + } + + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("🎵 Rhymes with **%s**: %s%s", word, strings.Join(display, ", "), more)) +} + +// difficultyTier maps a 0.0-1.0 score to a human-readable label. +func difficultyTier(score float64) string { + switch { + case score <= 0.25: + return "Easy" + case score <= 0.50: + return "Medium" + case score <= 0.75: + return "Hard" + default: + return "Brutal" + } +} + +// frequencyLabel maps a frequency score to a display label. +func frequencyLabel(freq int) string { + switch { + case freq > 500: + return "high" + case freq >= 100: + return "medium" + case freq > 0: + return "low" + default: + return "unknown" + } +} + +// langDisplayName returns a human-readable name for a language code. +func langDisplayName(lang string) string { + switch lang { + case "en": + return "English" + case "fr": + return "French" + case "pt-PT": + return "Portuguese" + case "zh": + return "Chinese" + default: + return lang + } +} diff --git a/internal/plugin/hangman.go b/internal/plugin/hangman.go index 6bfa891..6422e54 100644 --- a/internal/plugin/hangman.go +++ b/internal/plugin/hangman.go @@ -615,6 +615,14 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang p.games[ctx.RoomID] = game p.mu.Unlock() + // Fetch difficulty tier from DreamDict. + diffLabel := "" + if p.dict != nil { + if diff, err := p.dict.Difficulty(strings.ToLower(word), lang); err == nil && diff >= 0 { + diffLabel = " (" + difficultyTier(diff) + ")" + } + } + // Build thread root var sb strings.Builder if clueLang != "" { @@ -622,9 +630,9 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang if clueLangName == "" { clueLangName = clueLang } - sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s** (clue: %s)\n", langName, clueLangName)) + sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s%s** (clue: %s)\n", langName, diffLabel, clueLangName)) } else { - sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s**\n", langName)) + sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s%s**\n", langName, diffLabel)) } sb.WriteString(fmt.Sprintf("Word: %s (%d letters)\n", game.displayPhrase(), len([]rune(word)))) diff --git a/internal/plugin/lookup.go b/internal/plugin/lookup.go index 234121b..9baa4cf 100644 --- a/internal/plugin/lookup.go +++ b/internal/plugin/lookup.go @@ -193,6 +193,37 @@ func (p *LookupPlugin) handleDefine(ctx MessageContext) error { } } + // 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) diff --git a/internal/plugin/wotd.go b/internal/plugin/wotd.go index b8f24e5..5fed7f8 100644 --- a/internal/plugin/wotd.go +++ b/internal/plugin/wotd.go @@ -160,6 +160,19 @@ func (p *WOTDPlugin) pickWord(lang string) (string, string, string, string) { transMap["syn"] = synonyms } + // Fetch etymology + if etym, err := p.dict.Etymology(candidate, lang); err == nil && etym != "" { + // Truncate for WOTD display (max 200 chars). + if len(etym) > 200 { + if idx := strings.LastIndex(etym[:200], "."); idx > 100 { + etym = etym[:idx+1] + "..." + } else { + etym = etym[:200] + "..." + } + } + transMap["_etym"] = []string{etym} + } + var translationsJSON string if data, err := json.Marshal(transMap); err == nil { translationsJSON = string(data) @@ -414,6 +427,13 @@ func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, translationsJSON sb.WriteString("\n") } + // Etymology section (if available). + if transMap != nil { + if etym, ok := transMap["_etym"]; ok && len(etym) > 0 && etym[0] != "" { + sb.WriteString(fmt.Sprintf("Etymology\n %s\n\n", etym[0])) + } + } + sb.WriteString("━━━━━━━━━━━━━━━━━━━━\n") sb.WriteString(fmt.Sprintf("Learn more: `!define %s %s`\n", word, lang)) sb.WriteString("Use this word in a message today to earn 25 XP!") diff --git a/main.go b/main.go index 7a53ea8..786d99c 100644 --- a/main.go +++ b/main.go @@ -112,6 +112,7 @@ func main() { // Entertainment / Lookup registry.Register(plugin.NewRetroPlugin(client)) registry.Register(plugin.NewLookupPlugin(client, ratePlugin, dictClient)) + registry.Register(plugin.NewDictionaryPlugin(client, dictClient)) registry.Register(plugin.NewCountdownPlugin(client)) registry.Register(plugin.NewStocksPlugin(client)) forexPlugin := plugin.NewForexPlugin(client)