Files
gogobee/internal/plugin/fun.go
prosolis 9c6ded13fa Add forex plugin, stability fixes, and async HTTP dispatch
- Add forex plugin (Frankfurter v2 API) with rate lookups, analysis,
  DM-based alerts, and daily cron poll. Backfills 1 year of history
  on startup for moving averages and buy signal scoring.

- Fix bot hang caused by SQLite lock contention in reminder polling:
  rows cursor was held open while writing to the same DB. Collect
  results first, close cursor, then process. Same fix in milkcarton.

- Add sync retry loop so the bot reconnects after network drops
  instead of silently exiting. StopSync() for clean Ctrl+C shutdown.

- Add panic recovery to all dispatch, syncer, and cron paths.

- Make all HTTP-calling plugin commands async (goroutines) so a slow
  or dead external API cannot block the message dispatch pipeline.
  Affects: lookup, stocks, forex, anime, movies, concerts, gaming,
  retro, wotd, urls, howami.

- Extract DisplayName to Base, add db.Exec helper, convert silent
  error discards across the codebase.

- Fix UNO mercy-kill bug (eliminated bot continues playing), adventure
  DM nag spam, stats column mismatch, per-call regex/replacer allocs.

- Update README: forex commands, Finance section, 47 plugins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 09:22:02 -07:00

703 lines
22 KiB
Go

package plugin
import (
"bytes"
"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 <question>", Category: "Fun & Games"},
{Name: "coin", Description: "Flip a coin", Usage: "!coin", Category: "Fun & Games"},
{Name: "time", Description: "World clock or user's local time", Usage: "!time [city|@user]", Category: "Lookup & Reference"},
{Name: "hltb", Description: "HowLongToBeat lookup", Usage: "!hltb <game>", Category: "Lookup & Reference"},
{Name: "twinbee", Description: "Random Twinbee lore/trivia", Usage: "!twinbee", Category: "Fun & Games"},
{Name: "poll", Description: "Create a reaction poll", Usage: "!poll <question> | <option1> | <option2> ...", Category: "Fun & Games"},
{Name: "weather", Description: "Weather lookup", Usage: "!weather <location>", 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(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 <city> or !time <@user> for a specific location or person.")
return p.SendMessage(ctx.RoomID, sb.String())
}
// If it looks like a Matrix user ID, go straight to user lookup
if strings.HasPrefix(args, "@") && strings.Contains(args, ":") {
return p.showUserTime(ctx, args)
}
// Try city map first (cheap, deterministic lookup — avoids username collisions)
argsLower := strings.ToLower(args)
if tzName, ok := cityTimezones[argsLower]; ok {
loc, _ := time.LoadLocation(tzName)
t := time.Now().In(loc)
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🕐 %s: **%s**", titleCase(argsLower), t.Format("Monday, January 2, 2006 3:04 PM MST")))
}
// Try raw IANA timezone (preserve original case — IANA names are case-sensitive)
if loc, err := time.LoadLocation(args); err == nil {
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")))
}
// Fall back to user lookup
return p.showUserTime(ctx, args)
}
func (p *FunPlugin) showUserTime(ctx MessageContext, input string) error {
resolved, ok := p.ResolveUser(input, ctx.RoomID)
if !ok {
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Unknown city, timezone, or user: %s", input))
}
d := db.Get()
var tz string
err := d.QueryRow(`SELECT timezone FROM birthdays WHERE user_id = ?`, string(resolved)).Scan(&tz)
if err != nil || tz == "" {
return p.SendMessage(ctx.RoomID,
fmt.Sprintf("%s hasn't set their timezone yet. They can use !settz <timezone> to set it.", string(resolved)))
}
loc, err := time.LoadLocation(tz)
if err != nil {
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Invalid timezone stored for %s: %s", string(resolved), tz))
}
t := time.Now().In(loc)
return p.SendMessage(ctx.RoomID,
fmt.Sprintf("🕐 %s: **%s** (%s)", string(resolved), t.Format("Monday, January 2, 2006 3:04 PM"), tz))
}
func (p *FunPlugin) handleHLTB(ctx MessageContext) error {
gameName := strings.TrimSpace(p.GetArgs(ctx.Body, "hltb"))
if gameName == "" {
return p.SendMessage(ctx.RoomID, "Usage: !hltb <game name>")
}
// 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.Exec("hltb: cache result",
`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)
}
// hltbFetchToken gets a short-lived auth token from the HLTB finder init endpoint.
func hltbFetchToken() (string, error) {
client := &http.Client{Timeout: 5 * time.Second}
url := fmt.Sprintf("https://howlongtobeat.com/api/finder/init?t=%d", time.Now().UnixMilli())
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
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")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.Token == "" {
return "", fmt.Errorf("empty token from HLTB")
}
return result.Token, nil
}
func scrapeHLTB(gameName string) (string, error) {
token, err := hltbFetchToken()
if err != nil {
return "", fmt.Errorf("hltb token: %w", err)
}
payload := map[string]interface{}{
"searchType": "games",
"searchTerms": []string{gameName},
"searchPage": 1,
"size": 1,
"searchOptions": map[string]interface{}{
"games": map[string]interface{}{
"userId": 0,
"platform": "",
"sortCategory": "popular",
"rangeCategory": "main",
"rangeTime": map[string]interface{}{"min": nil, "max": nil},
"gameplay": map[string]interface{}{"perspective": "", "flow": "", "genre": "", "subGenre": "", "difficulty": ""},
"rangeYear": map[string]interface{}{"min": "", "max": ""},
"modifier": "",
},
"users": map[string]interface{}{"sortCategory": "postcount"},
"lists": map[string]interface{}{"sortCategory": "follows"},
"filter": "",
"sort": 0,
"randomizer": 0,
},
"useCache": true,
}
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", "https://howlongtobeat.com/api/finder", bytes.NewReader(data))
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")
req.Header.Set("x-auth-token", token)
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
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("hltb HTTP %d", resp.StatusCode)
}
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("\U0001f3ae **%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 <question> | <option1> | <option2> ...")
}
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 <location>")
}
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)
}