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)