mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
- Rate limit !weather (5/day), !concerts (10/day), fix !translate default to 20/day - Add generic api_cache table with CacheGet/CacheSet helpers - Cache !weather (1h), !wiki, !define, !tv (24h), !upcoming movies, !anime season/upcoming (24h) - Fix ProgressBar panic on negative Repeat count when XP exceeds level threshold - All rate limits configurable via RATELIMIT_* env vars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
603 lines
19 KiB
Go
603 lines
19 KiB
Go
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 <question>", 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 <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(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 <city> 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 <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.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 <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)
|
|
}
|