diff --git a/internal/plugin/hangman.go b/internal/plugin/hangman.go index 939bd5c..6bfa891 100644 --- a/internal/plugin/hangman.go +++ b/internal/plugin/hangman.go @@ -12,6 +12,7 @@ import ( "unicode" "gogobee/internal/db" + "gogobee/internal/dreamclient" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" @@ -121,6 +122,9 @@ type hangmanGame struct { solvedBy id.UserID earlySolve bool threadID id.EventID // thread root event for this game + lang string // puzzle word language (e.g. "pt-PT") + clueLang string // clue language (e.g. "en") + clueWord string // translated clue word } func newHangmanGame(phrase string, maxWrong int) *hangmanGame { @@ -272,6 +276,7 @@ func (g *hangmanGame) wrongGuessStr() string { type HangmanPlugin struct { Base euro *EuroPlugin + dict *dreamclient.Client phrases []string maxWrong int bonusMul float64 @@ -280,10 +285,11 @@ type HangmanPlugin struct { games map[id.RoomID]*hangmanGame } -func NewHangmanPlugin(client *mautrix.Client, euro *EuroPlugin) *HangmanPlugin { +func NewHangmanPlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclient.Client) *HangmanPlugin { return &HangmanPlugin{ Base: NewBase(client), euro: euro, + dict: dict, maxWrong: envInt("HANGMAN_MAX_WRONG_GUESSES", 6), bonusMul: envFloat("HANGMAN_SOLUTION_BONUS_MULTIPLIER", 2), games: make(map[id.RoomID]*hangmanGame), @@ -294,7 +300,7 @@ func (p *HangmanPlugin) Name() string { return "hangman" } func (p *HangmanPlugin) Commands() []CommandDef { return []CommandDef{ - {Name: "hangman", Description: "Collaborative Hangman game", Usage: "!hangman start [easy|medium|hard|extreme] | !hangman [letter/phrase] | !hangman submit [phrase]", Category: "Games"}, + {Name: "hangman", Description: "Collaborative Hangman game", Usage: "!hangman start [easy|medium|hard|extreme] | !hangman [lang] [--clue ] | !hangman submit [phrase]", Category: "Games"}, {Name: "hangboard", Description: "Hangman leaderboard", Usage: "!hangboard", Category: "Games"}, } } @@ -347,18 +353,49 @@ func (p *HangmanPlugin) OnMessage(ctx MessageContext) error { lower := strings.ToLower(args) switch { case args == "" || lower == "start": - return p.handleStart(ctx, "") + return p.handleStart(ctx, "", "", "") case strings.HasPrefix(lower, "start "): - return p.handleStart(ctx, strings.TrimSpace(args[6:])) + return p.handleStart(ctx, strings.TrimSpace(args[6:]), "", "") case strings.HasPrefix(lower, "submit "): return p.handleSubmit(ctx, strings.TrimSpace(args[7:])) case lower == "skip": return p.handleSkip(ctx) default: + // Check if this is a multilingual start: !hangman [--clue ] + if lang, clueLang, ok := p.parseMultilingualArgs(args); ok { + return p.handleStart(ctx, "", lang, clueLang) + } return p.handleGuess(ctx, args) } } +// parseMultilingualArgs parses "pt-PT --clue en" or "fr --clue en" style args. +// Returns (lang, clueLang, true) if the first token is a valid language tag. +func (p *HangmanPlugin) parseMultilingualArgs(args string) (string, string, bool) { + parts := strings.Fields(args) + if len(parts) == 0 { + return "", "", false + } + + lang := normaliseLang(parts[0]) + if lang == "" { + return "", "", false + } + + clueLang := "" + for i := 1; i < len(parts); i++ { + if strings.ToLower(parts[i]) == "--clue" && i+1 < len(parts) { + clueLang = normaliseLang(parts[i+1]) + if clueLang == "" { + return lang, "", false // invalid clue lang, will be handled in handleStart + } + break + } + } + + return lang, clueLang, true +} + // --------------------------------------------------------------------------- // Phrase management // --------------------------------------------------------------------------- @@ -415,7 +452,7 @@ func (p *HangmanPlugin) addPhrase(phrase string) error { // Game commands // --------------------------------------------------------------------------- -func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error { +func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty, lang, clueLang string) error { p.mu.Lock() if _, active := p.games[ctx.RoomID]; active { @@ -423,6 +460,31 @@ func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error return p.SendReply(ctx.RoomID, ctx.EventID, "A Hangman game is already in progress!") } + // Multilingual mode: use DreamDict for word selection + if lang != "" { + // Validate before reserving the slot + if p.dict == nil { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.") + } + if clueLang != "" && clueLang == lang { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Clue language must differ from the puzzle language.") + } + if clueLang != "" { + if normaliseLang(clueLang) == "" { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("❌ Unknown clue language \"%s\". Supported: en, fr, pt-PT", clueLang)) + } + } + + // Reserve the slot so no concurrent start can race us during network I/O + p.games[ctx.RoomID] = &hangmanGame{} // placeholder + p.mu.Unlock() + return p.startMultilingualGame(ctx, lang, clueLang) + } + if len(p.phrases) == 0 { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "No phrases loaded. Ask an admin to set up HANGMAN_PHRASE_FILE.") @@ -493,6 +555,107 @@ func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error )) } +// startMultilingualGame picks a DreamDict word and starts a multilingual hangman game. +// Caller must have already reserved p.games[ctx.RoomID] with a placeholder. +func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang string) error { + rollback := func() { + p.mu.Lock() + if g := p.games[ctx.RoomID]; g != nil && g.phrase == "" { + delete(p.games, ctx.RoomID) + } + p.mu.Unlock() + } + + langName := dreamdictLangNames[lang] + if langName == "" { + langName = lang + } + + // Pick a word, optionally with a translatable clue + var word, clueWord string + for attempt := 0; attempt < 3; attempt++ { + candidate, err := p.dict.RandomWord(lang, "", 4, 12) + if err != nil { + slog.Error("hangman: random word failed", "lang", lang, "err", err) + rollback() + return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.") + } + + if clueLang == "" { + word = candidate + break + } + + translations, err := p.dict.Translate(candidate, lang, clueLang) + if err != nil || len(translations) == 0 { + continue // retry with a different word + } + word = candidate + clueWord = translations[0] + break + } + + if word == "" { + // Fallback: start without a clue + candidate, err := p.dict.RandomWord(lang, "", 4, 12) + if err != nil { + rollback() + return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.") + } + word = candidate + } + + game := newHangmanGame(word, p.maxWrong) + game.lang = lang + game.clueLang = clueLang + game.clueWord = clueWord + + // Replace the placeholder reserved by handleStart + p.mu.Lock() + p.games[ctx.RoomID] = game + p.mu.Unlock() + + // Build thread root + var sb strings.Builder + if clueLang != "" { + clueLangName := dreamdictLangNames[clueLang] + if clueLangName == "" { + clueLangName = clueLang + } + sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s** (clue: %s)\n", langName, clueLangName)) + } else { + sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s**\n", langName)) + } + sb.WriteString(fmt.Sprintf("Word: %s (%d letters)\n", game.displayPhrase(), len([]rune(word)))) + + if clueWord != "" { + sb.WriteString(fmt.Sprintf("💡 Clue: %s\n", clueWord)) + } else if clueLang != "" { + sb.WriteString("💡 No clue available for this word.\n") + } + + sb.WriteString(fmt.Sprintf("\nWrong guesses: 0/%d\n", game.maxWrong)) + sb.WriteString("Guess a letter: `!hangman `") + + eventID, err := p.SendMessageID(ctx.RoomID, sb.String()) + if err != nil { + p.mu.Lock() + delete(p.games, ctx.RoomID) + p.mu.Unlock() + return err + } + + p.mu.Lock() + game.threadID = eventID + p.mu.Unlock() + + // Materialize thread + return p.SendThread(ctx.RoomID, eventID, fmt.Sprintf( + "```\n%s\n```\n%s\n\nWrong guesses: none", + gallows[0], game.displayPhrase(), + )) +} + func (p *HangmanPlugin) handleGuess(ctx MessageContext, guess string) error { p.mu.Lock() game, active := p.games[ctx.RoomID] diff --git a/internal/plugin/lookup.go b/internal/plugin/lookup.go index ff722ef..234121b 100644 --- a/internal/plugin/lookup.go +++ b/internal/plugin/lookup.go @@ -7,11 +7,11 @@ import ( "log/slog" "net/http" "net/url" - "os" "strings" "time" "gogobee/internal/db" + "gogobee/internal/dreamclient" "maunium.net/go/mautrix" ) @@ -22,13 +22,15 @@ var httpClient = &http.Client{Timeout: 15 * time.Second} type LookupPlugin struct { Base rateLimiter *RateLimitsPlugin + dict *dreamclient.Client } // NewLookupPlugin creates a new lookup plugin. -func NewLookupPlugin(client *mautrix.Client, rateLimiter *RateLimitsPlugin) *LookupPlugin { +func NewLookupPlugin(client *mautrix.Client, rateLimiter *RateLimitsPlugin, dict *dreamclient.Client) *LookupPlugin { return &LookupPlugin{ Base: NewBase(client), rateLimiter: rateLimiter, + dict: dict, } } @@ -39,7 +41,7 @@ func (p *LookupPlugin) Commands() []CommandDef { {Name: "wiki", Description: "Look up a Wikipedia summary", Usage: "!wiki ", Category: "Lookup & Reference"}, {Name: "define", Description: "Look up a word definition", Usage: "!define ", Category: "Lookup & Reference"}, {Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban ", Category: "Lookup & Reference"}, - {Name: "translate", Description: "Translate text (pt/es/fr/de/ja/ko/zh/ar/ru/it)", Usage: "!translate [lang] ", Category: "Lookup & Reference"}, + {Name: "translate", Description: "Look up word translations (en/fr/pt-PT)", Usage: "!translate [lang]", Category: "Lookup & Reference"}, } } @@ -283,78 +285,114 @@ func (p *LookupPlugin) handleUrban(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, msg) } -var supportedLangs = map[string]bool{ - "pt": true, "es": true, "fr": true, "de": true, "ja": true, - "ko": true, "zh": true, "ar": true, "ru": true, "it": true, +// 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 [lang] \nSupported: pt, es, fr, de, ja, ko, zh, ar, ru, it") + "Usage: !translate [lang]\nSupported languages: en, fr, pt-PT") } - ltURL := os.Getenv("LIBRETRANSLATE_URL") - if ltURL == "" { - return p.SendReply(ctx.RoomID, ctx.EventID, "Translation service is not configured.") + if p.dict == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.") } - // Rate limit (configurable, default 20/day to match TS version) - translateLimit := 20 - if v := os.Getenv("RATELIMIT_TRANSLATE"); v != "" { - if n, err := fmt.Sscanf(v, "%d", &translateLimit); n != 1 || err != nil { - translateLimit = 20 + 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 p.rateLimiter != nil && !p.rateLimiter.CheckLimit(ctx.Sender, "translate", translateLimit) { - remaining := p.rateLimiter.Remaining(ctx.Sender, "translate", translateLimit) - return p.SendReply(ctx.RoomID, ctx.EventID, - fmt.Sprintf("Translation rate limit reached. %d remaining today.", remaining)) + + // 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)) + } } - // Parse lang code and text - parts := strings.SplitN(args, " ", 2) - targetLang := "es" // default - text := args + // Translate to other languages + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🔤 %s (%s)\n", word, sourceLang)) - if len(parts) >= 2 && supportedLangs[strings.ToLower(parts[0])] { - targetLang = strings.ToLower(parts[0]) - text = parts[1] + 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)) } - // Call LibreTranslate - payload := fmt.Sprintf(`{"q":%q,"source":"auto","target":%q}`, text, targetLang) - req, err := http.NewRequest("POST", strings.TrimRight(ltURL, "/")+"/translate", strings.NewReader(payload)) - if err != nil { - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to create translation request.") - } - req.Header.Set("Content-Type", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - slog.Error("lookup: translate request", "err", err) - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach translation service.") - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read translation response.") - } - - var result struct { - TranslatedText string `json:"translatedText"` - Error string `json:"error"` - } - if err := json.Unmarshal(body, &result); err != nil { - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse translation response.") - } - - if result.Error != "" { - return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Translation error: %s", result.Error)) - } - - msg := fmt.Sprintf("Translation (-> %s):\n%s", targetLang, result.TranslatedText) - return p.SendReply(ctx.RoomID, ctx.EventID, msg) + return p.SendReply(ctx.RoomID, ctx.EventID, strings.TrimRight(sb.String(), "\n")) } diff --git a/internal/plugin/wotd.go b/internal/plugin/wotd.go index 1cb2a15..eb658e4 100644 --- a/internal/plugin/wotd.go +++ b/internal/plugin/wotd.go @@ -2,7 +2,6 @@ package plugin import ( "bytes" - "context" "database/sql" "encoding/json" "fmt" @@ -14,42 +13,23 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/dreamclient" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" ) -// wordnikWOTDResponse is the top-level Wordnik Word of the Day response. -type wordnikWOTDResponse struct { - Word string `json:"word"` - Definitions []wordnikWOTDDef `json:"definitions"` - Examples []wordnikWOTDEx `json:"examples"` -} - -type wordnikWOTDDef struct { - Text string `json:"text"` - PartOfSpeech string `json:"partOfSpeech"` -} - -type wordnikWOTDEx struct { - Text string `json:"text"` -} - -// WOTDPlugin provides a Word of the Day feature using the Wordnik API. +// WOTDPlugin provides a Word of the Day feature using DreamDict. type WOTDPlugin struct { Base - apiKey string - httpClient *http.Client + dict *dreamclient.Client } // NewWOTDPlugin creates a new WOTDPlugin. -func NewWOTDPlugin(client *mautrix.Client) *WOTDPlugin { +func NewWOTDPlugin(client *mautrix.Client, dict *dreamclient.Client) *WOTDPlugin { return &WOTDPlugin{ - Base: NewBase(client), - apiKey: os.Getenv("WORDNIK_API_KEY"), - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, + Base: NewBase(client), + dict: dict, } } @@ -57,7 +37,7 @@ func (p *WOTDPlugin) Name() string { return "wotd" } func (p *WOTDPlugin) Commands() []CommandDef { return []CommandDef{ - {Name: "wotd", Description: "Show today's Word of the Day", Usage: "!wotd", Category: "Lookup & Reference"}, + {Name: "wotd", Description: "Show today's Palavra do Dia", Usage: "!wotd [force]", Category: "Lookup & Reference"}, } } @@ -68,8 +48,15 @@ func (p *WOTDPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *WOTDPlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "wotd") { go func() { - if err := p.handleWOTD(ctx); err != nil { - slog.Error("wotd: handler error", "err", err) + args := strings.TrimSpace(p.GetArgs(ctx.Body, "wotd")) + if strings.ToLower(args) == "force" { + if err := p.handleWOTDForce(ctx); err != nil { + slog.Error("wotd: force handler error", "err", err) + } + } else { + if err := p.handleWOTD(ctx); err != nil { + slog.Error("wotd: handler error", "err", err) + } } }() return nil @@ -83,68 +70,96 @@ func (p *WOTDPlugin) OnMessage(ctx MessageContext) error { return nil } -// Prefetch fetches today's Word of the Day from Wordnik and stores it in the database. +// Prefetch picks today's Palavra do Dia from DreamDict and stores it in the database. +// Prefers pt-PT words with at least one definition and one English translation. func (p *WOTDPlugin) Prefetch() error { - if p.apiKey == "" { - slog.Warn("wotd: WORDNIK_API_KEY not set, skipping prefetch") + return p.prefetchWord(false) +} + +func (p *WOTDPlugin) prefetchWord(force bool) error { + if p.dict == nil { + slog.Warn("wotd: DreamDict not configured, skipping prefetch") return nil } today := time.Now().UTC().Format("2006-01-02") - - // Check if already fetched d := db.Get() - var exists int - err := d.QueryRow(`SELECT 1 FROM wotd_log WHERE date = ?`, today).Scan(&exists) - if err == nil { - slog.Info("wotd: already fetched for today", "date", today) - return nil + + if !force { + var exists int + err := d.QueryRow(`SELECT 1 FROM wotd_log WHERE date = ?`, today).Scan(&exists) + if err == nil { + slog.Info("wotd: already fetched for today", "date", today) + return nil + } } - url := fmt.Sprintf("https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=%s", p.apiKey) - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("wotd: create request: %w", err) + // Pick a random pt-PT word with definitions; prefer one with English translations. + var word, definition, partOfSpeech, translationsJSON string + + for attempt := 0; attempt < 10; attempt++ { + candidate, err := p.dict.RandomWord("pt-PT", "", 4, 14) + if err != nil { + slog.Warn("wotd: random word attempt failed", "attempt", attempt+1, "err", err) + continue + } + + defs, err := p.dict.Define(candidate, "pt-PT") + if err != nil || len(defs) == 0 { + continue + } + + // Check for English translation (preferred but not required after 5 attempts) + enTrans, _ := p.dict.Translate(candidate, "pt-PT", "en") + if len(enTrans) == 0 && attempt < 5 { + continue + } + + frTrans, _ := p.dict.Translate(candidate, "pt-PT", "fr") + + word = candidate + definition = defs[0].Gloss + partOfSpeech = defs[0].POS + + // Store translations as JSON in the example column + transMap := map[string][]string{} + if len(enTrans) > 0 { + transMap["en"] = enTrans + } + if len(frTrans) > 0 { + transMap["fr"] = frTrans + } + if data, err := json.Marshal(transMap); err == nil { + translationsJSON = string(data) + } + break } - resp, err := p.httpClient.Do(req) - if err != nil { - return fmt.Errorf("wotd: fetch: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("wotd: API returned status %d", resp.StatusCode) + if word == "" { + return fmt.Errorf("wotd: failed to find a pt-PT word with definitions after 10 attempts") } - var wotd wordnikWOTDResponse - if err := json.NewDecoder(resp.Body).Decode(&wotd); err != nil { - return fmt.Errorf("wotd: decode response: %w", err) + if force { + // Delete existing entry for today so the INSERT below replaces it + if _, delErr := d.Exec(`DELETE FROM wotd_log WHERE date = ?`, today); delErr != nil { + slog.Error("wotd: force delete failed", "err", delErr) + } + // Also clear job-completed flags so PostWOTD will re-post + d.Exec(`DELETE FROM job_completed WHERE job_name = 'wotd' AND job_key LIKE ?`, today+"%") } - definition := "" - partOfSpeech := "" - if len(wotd.Definitions) > 0 { - definition = wotd.Definitions[0].Text - partOfSpeech = wotd.Definitions[0].PartOfSpeech - } - - example := "" - if len(wotd.Examples) > 0 { - example = wotd.Examples[0].Text - } - - _, err = d.Exec( + _, err := d.Exec( `INSERT INTO wotd_log (date, word, definition, part_of_speech, example, posted) VALUES (?, ?, ?, ?, ?, 0) - ON CONFLICT(date) DO NOTHING`, - today, wotd.Word, definition, partOfSpeech, example, + ON CONFLICT(date) DO UPDATE SET word = ?, definition = ?, part_of_speech = ?, example = ?, posted = 0`, + today, word, definition, partOfSpeech, translationsJSON, + word, definition, partOfSpeech, translationsJSON, ) if err != nil { return fmt.Errorf("wotd: store: %w", err) } - slog.Info("wotd: prefetched", "date", today, "word", wotd.Word) + slog.Info("wotd: prefetched", "date", today, "word", word) return nil } @@ -216,26 +231,64 @@ func (p *WOTDPlugin) handleWOTD(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, msg) } -func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, example string) string { +func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, translationsJSON string) string { + now := time.Now().UTC() + dateStr := now.Format("Monday, 2 January 2006") + var sb strings.Builder - sb.WriteString(fmt.Sprintf("Word of the Day: %s", word)) + sb.WriteString(fmt.Sprintf("📖 **Palavra do Dia** — %s\n\n", dateStr)) if partOfSpeech != "" { - sb.WriteString(fmt.Sprintf(" (%s)", partOfSpeech)) + sb.WriteString(fmt.Sprintf("✨ **%s** (%s)\n\n", word, partOfSpeech)) + } else { + sb.WriteString(fmt.Sprintf("✨ **%s**\n\n", word)) } + // Portuguese definition if definition != "" { - sb.WriteString(fmt.Sprintf("\n\nDefinition: %s", definition)) + sb.WriteString(fmt.Sprintf("🇵🇹 pt-PT\n %s\n\n", definition)) } - if example != "" { - sb.WriteString(fmt.Sprintf("\n\nExample: \"%s\"", example)) + // Parse translations from JSON stored in example column + if translationsJSON != "" { + var transMap map[string][]string + if json.Unmarshal([]byte(translationsJSON), &transMap) == nil { + if enTrans, ok := transMap["en"]; ok && len(enTrans) > 0 { + display := enTrans + if len(display) > 5 { + display = display[:5] + } + sb.WriteString(fmt.Sprintf("🇬🇧 en\n %s\n\n", strings.Join(display, ", "))) + } + if frTrans, ok := transMap["fr"]; ok && len(frTrans) > 0 { + display := frTrans + if len(display) > 5 { + display = display[:5] + } + sb.WriteString(fmt.Sprintf("🇫🇷 fr\n %s\n\n", strings.Join(display, ", "))) + } + } } - sb.WriteString("\n\nUse this word in a message today to earn 25 XP!") + sb.WriteString("━━━━━━━━━━━━━━━━━━━━\n") + sb.WriteString(fmt.Sprintf("Learn more: `!define %s pt-PT`\n", word)) + sb.WriteString("Use this word in a message today to earn 25 XP!") return sb.String() } +func (p *WOTDPlugin) handleWOTDForce(ctx MessageContext) error { + if !p.IsAdmin(ctx.Sender) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Only moderators can force a new Word of the Day.") + } + + if err := p.prefetchWord(true); err != nil { + slog.Error("wotd: force prefetch failed", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch a new Word of the Day. Try again later.") + } + + return p.handleWOTD(ctx) +} + // trackUsage checks if the user used the WOTD in their message and rewards them. func (p *WOTDPlugin) trackUsage(ctx MessageContext) { today := time.Now().UTC().Format("2006-01-02") diff --git a/main.go b/main.go index 788c1e1..a057a27 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "gogobee/internal/bot" "gogobee/internal/db" + "gogobee/internal/dreamclient" "gogobee/internal/plugin" "gogobee/internal/util" @@ -95,9 +96,22 @@ func main() { registry.Register(plugin.NewToolsPlugin(client)) registry.Register(plugin.NewUserPlugin(client)) + // Dictionary service + var dictClient *dreamclient.Client + if dictURL := os.Getenv("DREAMDICT_URL"); dictURL != "" { + dictClient = dreamclient.New(dictURL) + if h, err := dictClient.Health(); err != nil { + slog.Warn("dreamdict not reachable — dictionary features degraded", "url", dictURL, "err", err) + } else { + slog.Info("dreamdict connected", "url", dictURL, "words_en", h.WordCounts["en"]) + } + } else { + slog.Warn("DREAMDICT_URL not set — dictionary features disabled") + } + // Entertainment / Lookup registry.Register(plugin.NewRetroPlugin(client)) - registry.Register(plugin.NewLookupPlugin(client, ratePlugin)) + registry.Register(plugin.NewLookupPlugin(client, ratePlugin, dictClient)) registry.Register(plugin.NewCountdownPlugin(client)) registry.Register(plugin.NewStocksPlugin(client)) forexPlugin := plugin.NewForexPlugin(client) @@ -113,13 +127,13 @@ func main() { euroPlugin := plugin.NewEuroPlugin(client) registry.Register(euroPlugin) registry.Register(plugin.NewFlipPlugin(client)) - registry.Register(plugin.NewHangmanPlugin(client, euroPlugin)) + registry.Register(plugin.NewHangmanPlugin(client, euroPlugin, dictClient)) registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin)) registry.Register(plugin.NewUnoPlugin(client, euroPlugin)) registry.Register(plugin.NewHoldemPlugin(client, euroPlugin)) adventurePlugin := plugin.NewAdventurePlugin(client, euroPlugin) registry.Register(adventurePlugin) - wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin) + wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin, dictClient) registry.Register(wordlePlugin) // Community @@ -144,7 +158,7 @@ func main() { registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin)) // Scheduled - wotdPlugin := plugin.NewWOTDPlugin(client) + wotdPlugin := plugin.NewWOTDPlugin(client, dictClient) registry.Register(wotdPlugin) holidaysPlugin := plugin.NewHolidaysPlugin(client) registry.Register(holidaysPlugin)