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>
This commit is contained in:
prosolis
2026-04-02 06:45:25 -07:00
parent a0573e67a5
commit 78cbd895f4
6 changed files with 551 additions and 2 deletions

View File

@@ -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")

View File

@@ -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 <word> [lang]", Category: "Lookup & Reference"},
{Name: "pronounce", Description: "Look up pronunciation of a word", Usage: "!pronounce <word> [lang]", Category: "Lookup & Reference"},
{Name: "etymology", Description: "Look up the etymology of a word", Usage: "!etymology <word> [lang]", Category: "Lookup & Reference"},
{Name: "difficulty", Description: "Show difficulty score for a word", Usage: "!difficulty <word> [lang]", Category: "Lookup & Reference"},
{Name: "rhyme", Description: "Find rhyming words (English)", Usage: "!rhyme <word>", 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 <word> [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 <word> [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 <word> [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 <word> [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 <word>`\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
}
}

View File

@@ -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))))

View File

@@ -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)

View File

@@ -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!")

View File

@@ -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)