mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Security & economy: - Credit()/Debit() reject non-positive amounts (closes infinite-money exploit) - Lottery ticket purchase uses in-transaction count check (closes TOCTOU race) - Arena bail channel ownership prevents double-close panic - Entry.ID validated before exec.Command in fetch-esteemed - Internal errors no longer leaked to users (forex, esteemed) Robustness: - safeGo() panic recovery added to 17 goroutine launch sites across 14 plugins - db.Close() added and called on shutdown - Miniflux mutex pattern fixed (snapshot-under-lock) - Silently ignored DB/JSON errors now logged (lottery_db) Performance: - Added indexes: idx_arena_runs_user(user_id, status), idx_euro_bal_user(user_id) UX: - Empty !buy args shows usage instead of "No item matching ''" - "Failed to load character" now suggests !adventure Test coverage: - internal/util/parser_test.go (19 tests: XP, levels, progress bar, commands, parsing) - internal/crypto/crypto_test.go (12 tests: encrypt/decrypt, HMAC, ParseKey) - internal/plugin/helpers_test.go (30 tests: formatNumber, calc, lottery, chat perks) - internal/plugin/audit_fixes_test.go (25 tests: safeGo, bail channels, error leaks, overflow) - internal/db/db_test.go (4 tests: Init, Close, schema indexes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
368 lines
9.9 KiB
Go
368 lines
9.9 KiB
Go
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
|
|
}
|
|
safeGo("dictionary-handler", 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
|
|
}
|
|
}
|