package plugin import ( "encoding/json" "fmt" "io" "log/slog" "math/rand/v2" "net/http" "net/url" "os" "regexp" "strconv" "strings" "time" "unicode" "gogobee/internal/db" "maunium.net/go/mautrix" ) // cityTimezones maps common city names to IANA timezone strings. var cityTimezones = map[string]string{ "new york": "America/New_York", "nyc": "America/New_York", "los angeles": "America/Los_Angeles", "la": "America/Los_Angeles", "chicago": "America/Chicago", "denver": "America/Denver", "london": "Europe/London", "paris": "Europe/Paris", "berlin": "Europe/Berlin", "tokyo": "Asia/Tokyo", "sydney": "Australia/Sydney", "dubai": "Asia/Dubai", "moscow": "Europe/Moscow", "mumbai": "Asia/Kolkata", "beijing": "Asia/Shanghai", "shanghai": "Asia/Shanghai", "singapore": "Asia/Singapore", "hong kong": "Asia/Hong_Kong", "seoul": "Asia/Seoul", "toronto": "America/Toronto", "vancouver": "America/Vancouver", "sao paulo": "America/Sao_Paulo", "mexico city": "America/Mexico_City", "cairo": "Africa/Cairo", "johannesburg": "Africa/Johannesburg", "auckland": "Pacific/Auckland", "honolulu": "Pacific/Honolulu", "hawaii": "Pacific/Honolulu", "anchorage": "America/Anchorage", "amsterdam": "Europe/Amsterdam", "rome": "Europe/Rome", "madrid": "Europe/Madrid", "lisbon": "Europe/Lisbon", "bangkok": "Asia/Bangkok", "jakarta": "Asia/Jakarta", "manila": "Asia/Manila", "taipei": "Asia/Taipei", "utc": "UTC", "gmt": "UTC", } var eightBallResponses = []string{ "It is certain.", "It is decidedly so.", "Without a doubt.", "Yes, definitely.", "You may rely on it.", "As I see it, yes.", "Most likely.", "Outlook good.", "Yes.", "Signs point to yes.", "Reply hazy, try again.", "Ask again later.", "Better not tell you now.", "Cannot predict now.", "Concentrate and ask again.", "Don't count on it.", "My reply is no.", "My sources say no.", "Outlook not so good.", "Very doubtful.", } var twinbeeFacts = []string{ "TwinBee was first released in 1985 by Konami for arcades in Japan.", "The TwinBee series is known as a 'cute 'em up' — a cute-themed shoot 'em up.", "TwinBee's main characters are sentient, bell-powered fighter ships.", "The bells in TwinBee change color when shot, granting different power-ups.", "WinBee, the pink ship, is piloted by Pastel in the later games.", "TwinBee Rainbow Bell Adventure is a rare platformer spinoff for the SNES.", "GwinBee is the green third ship, piloted by Mint in TwinBee Yahho!", "The TwinBee anime, 'TwinBee Paradise', aired as a radio drama in Japan.", "Detana!! TwinBee is considered one of the best entries and was a PC Engine hit.", "TwinBee Yahho! (1995) was the last major arcade release in the series.", "Pop'n TwinBee for the SNES featured a 2-player co-op mode with combined attacks.", "In TwinBee games, you punch clouds to release bells for power-ups.", "The TwinBee series inspired parts of Konami's Parodius games.", "Light and Pastel, the pilots of TwinBee and WinBee, are childhood friends.", "Dr. Cinnamon is the inventor who created the TwinBee ships.", "TwinBee 3: Poko Poko Daimaou featured an overworld map, mixing RPG and shooter elements.", } var diceRe = regexp.MustCompile(`(?i)^(\d+)?d(\d+)([+-]\d+)?$`) // FunPlugin provides various fun and utility commands. type FunPlugin struct { Base rateLimiter *RateLimitsPlugin } // NewFunPlugin creates a new FunPlugin. func NewFunPlugin(client *mautrix.Client, rateLimiter *RateLimitsPlugin) *FunPlugin { return &FunPlugin{ Base: NewBase(client), rateLimiter: rateLimiter, } } func (p *FunPlugin) Name() string { return "fun" } func (p *FunPlugin) Commands() []CommandDef { return []CommandDef{ {Name: "roll", Description: "Roll dice", Usage: "!roll [NdM+X]", Category: "Fun & Games"}, {Name: "8ball", Description: "Magic 8-ball", Usage: "!8ball ", Category: "Fun & Games"}, {Name: "coin", Description: "Flip a coin", Usage: "!coin", Category: "Fun & Games"}, {Name: "time", Description: "World clock", Usage: "!time [city]", Category: "Lookup & Reference"}, {Name: "hltb", Description: "HowLongToBeat lookup", Usage: "!hltb ", Category: "Lookup & Reference"}, {Name: "twinbee", Description: "Random Twinbee lore/trivia", Usage: "!twinbee", Category: "Fun & Games"}, {Name: "poll", Description: "Create a reaction poll", Usage: "!poll | | ...", Category: "Fun & Games"}, {Name: "weather", Description: "Weather lookup", Usage: "!weather ", Category: "Lookup & Reference"}, {Name: "dadjoke", Description: "Random dad joke", Usage: "!dadjoke", Category: "Fun & Games"}, {Name: "randomwiki", Description: "Random Wikipedia article", Usage: "!randomwiki", Category: "Fun & Games"}, } } func (p *FunPlugin) Init() error { return nil } func (p *FunPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *FunPlugin) OnMessage(ctx MessageContext) error { switch { case p.IsCommand(ctx.Body, "roll"): return p.handleRoll(ctx) case p.IsCommand(ctx.Body, "8ball"): return p.handleEightBall(ctx) case p.IsCommand(ctx.Body, "coin"): return p.handleCoin(ctx) case p.IsCommand(ctx.Body, "time"): return p.handleTime(ctx) case p.IsCommand(ctx.Body, "hltb"): return p.handleHLTB(ctx) case p.IsCommand(ctx.Body, "twinbee"): return p.handleTwinbee(ctx) case p.IsCommand(ctx.Body, "poll"): return p.handlePoll(ctx) case p.IsCommand(ctx.Body, "weather"): return p.handleWeather(ctx) case p.IsCommand(ctx.Body, "dadjoke"): return p.handleDadJoke(ctx) case p.IsCommand(ctx.Body, "randomwiki"): return p.handleRandomWiki(ctx) } return nil } func (p *FunPlugin) handleRoll(ctx MessageContext) error { args := strings.TrimSpace(p.GetArgs(ctx.Body, "roll")) numDice := 1 sides := 6 modifier := 0 if args != "" { matches := diceRe.FindStringSubmatch(args) if matches == nil { return p.SendMessage(ctx.RoomID, "Invalid dice format. Use NdM+X (e.g., 2d6+3, d20, 4d8-1)") } if matches[1] != "" { numDice, _ = strconv.Atoi(matches[1]) } sides, _ = strconv.Atoi(matches[2]) if matches[3] != "" { modifier, _ = strconv.Atoi(matches[3]) } } if numDice < 1 || numDice > 100 { return p.SendMessage(ctx.RoomID, "Number of dice must be between 1 and 100.") } if sides < 2 || sides > 1000 { return p.SendMessage(ctx.RoomID, "Number of sides must be between 2 and 1000.") } rolls := make([]int, numDice) total := 0 for i := range numDice { rolls[i] = rand.IntN(sides) + 1 total += rolls[i] } total += modifier var result string if numDice == 1 && modifier == 0 { result = fmt.Sprintf("šŸŽ² You rolled a **%d** (d%d)", total, sides) } else { rollStrs := make([]string, len(rolls)) for i, r := range rolls { rollStrs[i] = strconv.Itoa(r) } modStr := "" if modifier > 0 { modStr = fmt.Sprintf("+%d", modifier) } else if modifier < 0 { modStr = fmt.Sprintf("%d", modifier) } result = fmt.Sprintf("šŸŽ² Rolled %dd%d%s: [%s] = **%d**", numDice, sides, modStr, strings.Join(rollStrs, ", "), total) } return p.SendMessage(ctx.RoomID, result) } func (p *FunPlugin) handleEightBall(ctx MessageContext) error { question := strings.TrimSpace(p.GetArgs(ctx.Body, "8ball")) if question == "" { return p.SendMessage(ctx.RoomID, "šŸŽ± You need to ask a question!") } response := eightBallResponses[rand.IntN(len(eightBallResponses))] return p.SendMessage(ctx.RoomID, fmt.Sprintf("šŸŽ± %s", response)) } func (p *FunPlugin) handleCoin(ctx MessageContext) error { if rand.IntN(2) == 0 { return p.SendMessage(ctx.RoomID, "šŸŖ™ **Heads!**") } return p.SendMessage(ctx.RoomID, "šŸŖ™ **Tails!**") } func (p *FunPlugin) handleTime(ctx MessageContext) error { args := strings.TrimSpace(strings.ToLower(p.GetArgs(ctx.Body, "time"))) if args == "" { // Show a few major cities cities := []string{"new york", "london", "tokyo", "sydney"} var sb strings.Builder sb.WriteString("šŸ• World Clock:\n") for _, city := range cities { tz, _ := time.LoadLocation(cityTimezones[city]) t := time.Now().In(tz) sb.WriteString(fmt.Sprintf(" • %s: %s\n", titleCase(city), t.Format("Mon Jan 2 15:04 MST"))) } sb.WriteString("\nUse !time for a specific location.") return p.SendMessage(ctx.RoomID, sb.String()) } tzName, ok := cityTimezones[args] if !ok { // Try loading it as a raw IANA zone tzName = args } loc, err := time.LoadLocation(tzName) if err != nil { return p.SendMessage(ctx.RoomID, fmt.Sprintf("Unknown city or timezone: %s", args)) } t := time.Now().In(loc) return p.SendMessage(ctx.RoomID, fmt.Sprintf("šŸ• %s: **%s**", args, t.Format("Monday, January 2, 2006 3:04 PM MST"))) } func (p *FunPlugin) handleHLTB(ctx MessageContext) error { gameName := strings.TrimSpace(p.GetArgs(ctx.Body, "hltb")) if gameName == "" { return p.SendMessage(ctx.RoomID, "Usage: !hltb ") } // Check cache (24 hour TTL) var cachedData string var cachedAt int64 err := db.Get().QueryRow( `SELECT data, cached_at FROM hltb_cache WHERE game_name = ?`, strings.ToLower(gameName), ).Scan(&cachedData, &cachedAt) if err == nil && time.Now().Unix()-cachedAt < 86400 { return p.SendMessage(ctx.RoomID, cachedData) } // Scrape HLTB result, err := scrapeHLTB(gameName) if err != nil { slog.Error("hltb: scrape failed", "err", err, "game", gameName) return p.SendMessage(ctx.RoomID, "Failed to look up that game on HowLongToBeat.") } if result == "" { return p.SendMessage(ctx.RoomID, fmt.Sprintf("No results found for \"%s\" on HowLongToBeat.", gameName)) } // Cache result _, _ = db.Get().Exec( `INSERT INTO hltb_cache (game_name, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(game_name) DO UPDATE SET data = ?, cached_at = ?`, strings.ToLower(gameName), result, time.Now().Unix(), result, time.Now().Unix(), ) return p.SendMessage(ctx.RoomID, result) } func scrapeHLTB(gameName string) (string, error) { payload := fmt.Sprintf(`{"searchType":"games","searchTerms":["%s"],"searchPage":1,"size":1,"searchOptions":{"games":{"userId":0,"platform":"","sortCategory":"popular","rangeCategory":"main","rangeTime":{"min":null,"max":null},"gameplay":{"perspective":"","flow":"","genre":"","subGenre":""},"rangeYear":{"min":"","max":""},"modifier":""},"users":{"sortCategory":"postcount"},"lists":{"sortCategory":"follows"},"filter":"","sort":0,"randomizer":0}}`, strings.ReplaceAll(gameName, `"`, `\"`)) req, err := http.NewRequest("POST", "https://howlongtobeat.com/api/search", strings.NewReader(payload)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36") req.Header.Set("Referer", "https://howlongtobeat.com") req.Header.Set("Origin", "https://howlongtobeat.com") client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } var result struct { Data []struct { GameName string `json:"game_name"` CompMain float64 `json:"comp_main"` CompPlus float64 `json:"comp_plus"` CompAll float64 `json:"comp_100"` ProfileDev string `json:"profile_dev"` ReleaseWorld int `json:"release_world"` } `json:"data"` } if err := json.Unmarshal(body, &result); err != nil { return "", err } if len(result.Data) == 0 { return "", nil } game := result.Data[0] formatTime := func(seconds float64) string { if seconds <= 0 { return "N/A" } hours := seconds / 3600.0 if hours < 1 { return fmt.Sprintf("%.0f min", seconds/60.0) } return fmt.Sprintf("%.1f hours", hours) } var sb strings.Builder sb.WriteString(fmt.Sprintf("šŸŽ® **%s**\n", game.GameName)) sb.WriteString(fmt.Sprintf(" Main Story: %s\n", formatTime(game.CompMain))) sb.WriteString(fmt.Sprintf(" Main + Extras: %s\n", formatTime(game.CompPlus))) sb.WriteString(fmt.Sprintf(" Completionist: %s\n", formatTime(game.CompAll))) if game.ProfileDev != "" { sb.WriteString(fmt.Sprintf(" Developer: %s\n", game.ProfileDev)) } return sb.String(), nil } func (p *FunPlugin) handleTwinbee(ctx MessageContext) error { fact := twinbeeFacts[rand.IntN(len(twinbeeFacts))] return p.SendMessage(ctx.RoomID, fmt.Sprintf("šŸ **Twinbee Trivia:** %s", fact)) } func (p *FunPlugin) handlePoll(ctx MessageContext) error { args := p.GetArgs(ctx.Body, "poll") if args == "" { return p.SendMessage(ctx.RoomID, "Usage: !poll | | ...") } parts := strings.Split(args, "|") if len(parts) < 3 { return p.SendMessage(ctx.RoomID, "A poll needs a question and at least 2 options, separated by |") } if len(parts) > 11 { return p.SendMessage(ctx.RoomID, "Maximum 10 options allowed.") } question := strings.TrimSpace(parts[0]) options := make([]string, 0, len(parts)-1) for _, opt := range parts[1:] { trimmed := strings.TrimSpace(opt) if trimmed != "" { options = append(options, trimmed) } } numberEmojis := []string{"1\uFE0F\u20E3", "2\uFE0F\u20E3", "3\uFE0F\u20E3", "4\uFE0F\u20E3", "5\uFE0F\u20E3", "6\uFE0F\u20E3", "7\uFE0F\u20E3", "8\uFE0F\u20E3", "9\uFE0F\u20E3", "\U0001F51F"} var sb strings.Builder sb.WriteString(fmt.Sprintf("šŸ“Š **Poll: %s**\n\n", question)) for i, opt := range options { if i < len(numberEmojis) { sb.WriteString(fmt.Sprintf("%s %s\n", numberEmojis[i], opt)) } } sb.WriteString("\nReact with the number to vote!") if err := p.SendMessage(ctx.RoomID, sb.String()); err != nil { return err } return nil } func (p *FunPlugin) handleWeather(ctx MessageContext) error { location := strings.TrimSpace(p.GetArgs(ctx.Body, "weather")) if location == "" { return p.SendMessage(ctx.RoomID, "Usage: !weather ") } apiKey := os.Getenv("OPENWEATHER_API_KEY") if apiKey == "" { return p.SendMessage(ctx.RoomID, "Weather service is not configured (missing API key).") } // Rate limit (configurable, default 5/day) weatherLimit := 5 if v := os.Getenv("RATELIMIT_WEATHER"); v != "" { if n, err := fmt.Sscanf(v, "%d", &weatherLimit); n != 1 || err != nil { weatherLimit = 5 } } if p.rateLimiter != nil && !p.rateLimiter.CheckLimit(ctx.Sender, "weather", weatherLimit) { return p.SendReply(ctx.RoomID, ctx.EventID, "Weather lookup rate limit reached for today.") } // Check 1-hour cache (weather data doesn't change that fast) cacheKey := "weather:" + strings.ToLower(location) if cached := db.CacheGet(cacheKey, 3600); cached != "" { return p.SendMessage(ctx.RoomID, cached) } apiURL := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=metric", url.QueryEscape(location), apiKey) resp, err := http.Get(apiURL) if err != nil { slog.Error("weather: API request failed", "err", err) return p.SendMessage(ctx.RoomID, "Failed to fetch weather data.") } defer resp.Body.Close() if resp.StatusCode == 404 { return p.SendMessage(ctx.RoomID, fmt.Sprintf("Location \"%s\" not found.", location)) } body, err := io.ReadAll(resp.Body) if err != nil { return p.SendMessage(ctx.RoomID, "Failed to read weather data.") } var weather struct { Name string `json:"name"` Main struct { Temp float64 `json:"temp"` FeelsLike float64 `json:"feels_like"` Humidity int `json:"humidity"` TempMin float64 `json:"temp_min"` TempMax float64 `json:"temp_max"` } `json:"main"` Weather []struct { Description string `json:"description"` } `json:"weather"` Wind struct { Speed float64 `json:"speed"` } `json:"wind"` Sys struct { Country string `json:"country"` } `json:"sys"` } if err := json.Unmarshal(body, &weather); err != nil { slog.Error("weather: parse response", "err", err) return p.SendMessage(ctx.RoomID, "Failed to parse weather data.") } desc := "N/A" if len(weather.Weather) > 0 { desc = weather.Weather[0].Description } tempF := weather.Main.Temp*9.0/5.0 + 32 feelsLikeF := weather.Main.FeelsLike*9.0/5.0 + 32 var sb strings.Builder sb.WriteString(fmt.Sprintf("šŸŒ¤ļø **Weather in %s, %s**\n", weather.Name, weather.Sys.Country)) sb.WriteString(fmt.Sprintf(" Condition: %s\n", titleCase(desc))) sb.WriteString(fmt.Sprintf(" Temperature: %.1f°C (%.1f°F)\n", weather.Main.Temp, tempF)) sb.WriteString(fmt.Sprintf(" Feels Like: %.1f°C (%.1f°F)\n", weather.Main.FeelsLike, feelsLikeF)) sb.WriteString(fmt.Sprintf(" Humidity: %d%%\n", weather.Main.Humidity)) sb.WriteString(fmt.Sprintf(" Wind: %.1f m/s\n", weather.Wind.Speed)) msg := sb.String() db.CacheSet(cacheKey, msg) return p.SendMessage(ctx.RoomID, msg) } func (p *FunPlugin) handleDadJoke(ctx MessageContext) error { req, err := http.NewRequest("GET", "https://icanhazdadjoke.com/", nil) if err != nil { return p.SendMessage(ctx.RoomID, "Failed to fetch a dad joke.") } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "GogoBee Matrix Bot") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { slog.Error("dadjoke: request failed", "err", err) return p.SendMessage(ctx.RoomID, "Failed to fetch a dad joke.") } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return p.SendMessage(ctx.RoomID, "Failed to read dad joke response.") } var joke struct { Joke string `json:"joke"` } if err := json.Unmarshal(body, &joke); err != nil { return p.SendMessage(ctx.RoomID, "Failed to parse dad joke.") } return p.SendMessage(ctx.RoomID, fmt.Sprintf("šŸ˜„ %s", joke.Joke)) } func (p *FunPlugin) handleRandomWiki(ctx MessageContext) error { req, err := http.NewRequest("GET", "https://en.wikipedia.org/api/rest_v1/page/random/summary", nil) if err != nil { return p.SendMessage(ctx.RoomID, "Failed to fetch a random article.") } req.Header.Set("User-Agent", "GogoBee Matrix Bot") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { slog.Error("randomwiki: request failed", "err", err) return p.SendMessage(ctx.RoomID, "Failed to fetch a random Wikipedia article.") } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return p.SendMessage(ctx.RoomID, "Failed to read Wikipedia response.") } var article struct { Title string `json:"title"` Extract string `json:"extract"` ContentURLs struct { Desktop struct { Page string `json:"page"` } `json:"desktop"` } `json:"content_urls"` } if err := json.Unmarshal(body, &article); err != nil { return p.SendMessage(ctx.RoomID, "Failed to parse Wikipedia response.") } extract := article.Extract if len(extract) > 300 { extract = extract[:300] + "..." } var sb strings.Builder sb.WriteString(fmt.Sprintf("šŸ“– **%s**\n", article.Title)) sb.WriteString(extract) if article.ContentURLs.Desktop.Page != "" { sb.WriteString(fmt.Sprintf("\nšŸ”— %s", article.ContentURLs.Desktop.Page)) } return p.SendMessage(ctx.RoomID, sb.String()) } // titleCase capitalizes the first letter of each word (replacement for deprecated strings.Title). func titleCase(s string) string { prev := ' ' return strings.Map(func(r rune) rune { if unicode.IsSpace(rune(prev)) || prev == ' ' { prev = r return unicode.ToTitle(r) } prev = r return r }, s) }