Add horoscope, !time @user, profile achievements; fix mention detection and multiple bugs

- Add !horoscope command and daily horoscope digest using Free Horoscope API
- Enhance !time to support @user timezone lookups
- Add profile completeness achievements (birthday, timezone, both set)
- Fix Matrix mention detection in reputation and LLM passive plugins
- Fix !whois reputation query (reason string mismatch and calculation)
- Fix !time IANA case-sensitivity and username/city collision
- Fix timezone default to empty string for achievement detection
- Scope all leaderboards to room members (XP, rep, potty, insult, first, rankings, emoji)
- Fix XP rank calculation to handle tied scores correctly
- Replace skull emoji with bomb for bot insult reactions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-09 23:55:02 -07:00
parent 2c191ec464
commit 03def6463f
24 changed files with 5057 additions and 116 deletions

View File

@@ -0,0 +1,216 @@
package plugin
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// zodiacSign maps month/day to a zodiac sign name.
func zodiacSign(month, day int) string {
switch {
case (month == 3 && day >= 21) || (month == 4 && day <= 19):
return "aries"
case (month == 4 && day >= 20) || (month == 5 && day <= 20):
return "taurus"
case (month == 5 && day >= 21) || (month == 6 && day <= 20):
return "gemini"
case (month == 6 && day >= 21) || (month == 7 && day <= 22):
return "cancer"
case (month == 7 && day >= 23) || (month == 8 && day <= 22):
return "leo"
case (month == 8 && day >= 23) || (month == 9 && day <= 22):
return "virgo"
case (month == 9 && day >= 23) || (month == 10 && day <= 22):
return "libra"
case (month == 10 && day >= 23) || (month == 11 && day <= 21):
return "scorpio"
case (month == 11 && day >= 22) || (month == 12 && day <= 21):
return "sagittarius"
case (month == 12 && day >= 22) || (month == 1 && day <= 19):
return "capricorn"
case (month == 1 && day >= 20) || (month == 2 && day <= 18):
return "aquarius"
case (month == 2 && day >= 19) || (month == 3 && day <= 20):
return "pisces"
}
return ""
}
var zodiacEmoji = map[string]string{
"aries": "♈",
"taurus": "♉",
"gemini": "♊",
"cancer": "♋",
"leo": "♌",
"virgo": "♍",
"libra": "♎",
"scorpio": "♏",
"sagittarius": "♐",
"capricorn": "♑",
"aquarius": "♒",
"pisces": "♓",
}
// HoroscopePlugin provides daily horoscope readings based on user birthdays.
type HoroscopePlugin struct {
Base
}
// NewHoroscopePlugin creates a new HoroscopePlugin.
func NewHoroscopePlugin(client *mautrix.Client) *HoroscopePlugin {
return &HoroscopePlugin{
Base: NewBase(client),
}
}
func (p *HoroscopePlugin) Name() string { return "horoscope" }
func (p *HoroscopePlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "horoscope", Description: "Get your daily horoscope (requires birthday)", Usage: "!horoscope", Category: "Fun & Games"},
}
}
func (p *HoroscopePlugin) Init() error { return nil }
func (p *HoroscopePlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *HoroscopePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "horoscope") {
return p.handleHoroscope(ctx)
}
return nil
}
func (p *HoroscopePlugin) handleHoroscope(ctx MessageContext) error {
// Check if user has a birthday set
d := db.Get()
var month, day int
err := d.QueryRow(
`SELECT month, day FROM birthdays WHERE user_id = ? AND month > 0 AND day > 0`,
string(ctx.Sender),
).Scan(&month, &day)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID,
"You need to set your birthday first! Use !birthday set <MM-DD> to get your horoscope.")
}
sign := zodiacSign(month, day)
if sign == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not determine your zodiac sign. Check your birthday with !birthday show.")
}
// Check cache (horoscopes are daily, cache for 6 hours)
cacheKey := fmt.Sprintf("horoscope:%s", sign)
if cached := db.CacheGet(cacheKey, 21600); cached != "" {
emoji := zodiacEmoji[sign]
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s **%s Horoscope** (%s)\n\n%s", emoji, titleCase(sign), time.Now().UTC().Format("Jan 2"), cached))
}
// Fetch from API
horoscope, err := fetchHoroscope(sign)
if err != nil {
slog.Error("horoscope: fetch failed", "err", err, "sign", sign)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch your horoscope. Try again later.")
}
// Cache the result
db.CacheSet(cacheKey, horoscope)
emoji := zodiacEmoji[sign]
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s **%s Horoscope** (%s)\n\n%s", emoji, titleCase(sign), time.Now().UTC().Format("Jan 2"), horoscope))
}
func fetchHoroscope(sign string) (string, error) {
url := fmt.Sprintf("https://freehoroscopeapi.com/api/v1/get-horoscope/daily?sign=%s", sign)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read body: %w", err)
}
var result struct {
Data struct {
Horoscope string `json:"horoscope"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if result.Data.Horoscope == "" {
return "", fmt.Errorf("empty horoscope in response")
}
return strings.TrimSpace(result.Data.Horoscope), nil
}
// PostDailyHoroscopes posts all 12 horoscopes to a room as a daily digest.
func (p *HoroscopePlugin) PostDailyHoroscopes(roomID id.RoomID) {
dateKey := time.Now().UTC().Format("2006-01-02")
if db.JobCompleted("horoscope", dateKey+":"+string(roomID)) {
return
}
signs := []string{
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Daily Horoscopes — %s\n\n", time.Now().UTC().Format("Monday, January 2")))
fetched := 0
for _, sign := range signs {
horoscope, err := fetchHoroscope(sign)
if err != nil {
slog.Error("horoscope: daily fetch", "sign", sign, "err", err)
continue
}
// Cache each one
db.CacheSet(fmt.Sprintf("horoscope:%s", sign), horoscope)
emoji := zodiacEmoji[sign]
sb.WriteString(fmt.Sprintf("%s **%s**\n%s\n\n", emoji, titleCase(sign), horoscope))
fetched++
}
if fetched == 0 {
slog.Error("horoscope: no signs fetched for daily post")
return
}
sb.WriteString("Set your birthday with !birthday set <MM-DD> to get personalized readings!")
if err := p.SendMessage(roomID, sb.String()); err != nil {
slog.Error("horoscope: post daily", "room", roomID, "err", err)
return
}
db.MarkJobCompleted("horoscope", dateKey+":"+string(roomID))
}