Files
gogobee/internal/plugin/fun.go
prosolis 1c02732445 Add tarot readings, encrypted quote wall, display name resolution, and uptime reporting
New features:
- !tarot and !tarotspread commands with LLM-powered readings (zodiac-aware)
- !quote system with AES-256-GCM encryption at rest, reply-to-save, search, and quoteboard
- Shared crypto utility (internal/crypto) for AES-256-GCM encrypt/decrypt/HMAC
- ResolveUser now matches display names via room membership as fallback
- !vibe and !tldr show bot uptime when insufficient messages buffered

Bug fixes:
- Fix duplicate quotes table schema that would crash quotewall at runtime
- Fix biased random selection in quote retrieval (use rand.Intn)
- Fix XP rank calculation to handle tied scores with COUNT(DISTINCT xp)
- Scope all leaderboards to room members (repboard, pottyboard, insultboard, firstboard, rankings, emojiboard)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:08:05 -07:00

635 lines
21 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 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.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)
}