mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Complete rewrite of the Freebee Matrix bot as GogoBee using mautrix-go. - E2EE with goolm (pure Go, no CGo/libolm) and cross-signing bootstrap - 35+ plugins with dependency injection and ordered registration - SQLite storage via modernc.org/sqlite (no CGo) - Scheduler via robfig/cron for WOTD, holidays, birthdays, releases, etc. - Optional LLM integration (Ollama) for sentiment, profanity, roasts, vibes - Threaded trivia, three-tier profanity tracking, WOTD usage verification - Multi-country holiday support with deduplication - Quadratic XP curve, configurable bot display name, encrypted DMs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
575 lines
15 KiB
Go
575 lines
15 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// Holiday represents a single holiday entry.
|
|
type Holiday struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Country string `json:"country"`
|
|
Type string `json:"type"`
|
|
Date string `json:"date"`
|
|
}
|
|
|
|
// holidaysDayData holds all holidays for a given date.
|
|
type holidaysDayData struct {
|
|
Holidays []Holiday `json:"holidays"`
|
|
}
|
|
|
|
// calendarificResponse is the Calendarific API response structure.
|
|
type calendarificResponse struct {
|
|
Response struct {
|
|
Holidays []struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Country struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"country"`
|
|
Type []string `json:"type"`
|
|
Date struct {
|
|
ISO string `json:"iso"`
|
|
} `json:"date"`
|
|
} `json:"holidays"`
|
|
} `json:"response"`
|
|
}
|
|
|
|
// hebcalResponse is the HebCal API response structure.
|
|
type hebcalResponse struct {
|
|
Items []struct {
|
|
Title string `json:"title"`
|
|
Date string `json:"date"`
|
|
Category string `json:"category"`
|
|
Memo string `json:"memo"`
|
|
} `json:"items"`
|
|
}
|
|
|
|
// aladhanResponse is the Aladhan API response for Hijri date conversion.
|
|
type aladhanResponse struct {
|
|
Data struct {
|
|
Hijri struct {
|
|
Date string `json:"date"`
|
|
Month struct {
|
|
En string `json:"en"`
|
|
} `json:"month"`
|
|
Year string `json:"year"`
|
|
Day string `json:"day"`
|
|
} `json:"hijri"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
// HolidaysPlugin provides multi-calendar holiday announcements.
|
|
type HolidaysPlugin struct {
|
|
Base
|
|
calendarificKey string
|
|
countries []string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewHolidaysPlugin creates a new HolidaysPlugin.
|
|
func NewHolidaysPlugin(client *mautrix.Client) *HolidaysPlugin {
|
|
countries := []string{
|
|
"US", "GB", "CA", "AU", "PT", "IN", "JP",
|
|
"DE", "FR", "BR", "MX", "IT", "ES", "KR",
|
|
"NL", "SE", "NO", "IE", "NZ", "ZA", "PH",
|
|
}
|
|
if env := os.Getenv("HOLIDAY_COUNTRIES"); env != "" {
|
|
countries = nil
|
|
for _, c := range strings.Split(env, ",") {
|
|
c = strings.TrimSpace(strings.ToUpper(c))
|
|
if c != "" {
|
|
countries = append(countries, c)
|
|
}
|
|
}
|
|
}
|
|
|
|
return &HolidaysPlugin{
|
|
Base: NewBase(client),
|
|
calendarificKey: os.Getenv("CALENDARIFIC_API_KEY"),
|
|
countries: countries,
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *HolidaysPlugin) Name() string { return "holidays" }
|
|
|
|
func (p *HolidaysPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "holidays", Description: "Show today's holidays (or week/month)", Usage: "!holidays [week|month]", Category: "Holidays"},
|
|
}
|
|
}
|
|
|
|
func (p *HolidaysPlugin) Init() error { return nil }
|
|
|
|
func (p *HolidaysPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
func (p *HolidaysPlugin) OnMessage(ctx MessageContext) error {
|
|
if p.IsCommand(ctx.Body, "holidays") {
|
|
return p.handleHolidays(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Prefetch fetches today's holidays from all sources and stores them.
|
|
func (p *HolidaysPlugin) Prefetch() error {
|
|
today := time.Now().UTC()
|
|
dateStr := today.Format("2006-01-02")
|
|
|
|
d := db.Get()
|
|
var exists int
|
|
err := d.QueryRow(`SELECT 1 FROM holidays_log WHERE date = ?`, dateStr).Scan(&exists)
|
|
if err == nil {
|
|
slog.Info("holidays: already fetched for today", "date", dateStr)
|
|
return nil
|
|
}
|
|
|
|
var allHolidays []Holiday
|
|
|
|
// Fetch from Calendarific for multiple countries
|
|
if p.calendarificKey != "" {
|
|
for _, country := range p.countries {
|
|
holidays, err := p.fetchCalendarific(today, country)
|
|
if err != nil {
|
|
slog.Error("holidays: calendarific fetch failed", "country", country, "err", err)
|
|
} else {
|
|
allHolidays = append(allHolidays, holidays...)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch from HebCal
|
|
holidays, err := p.fetchHebCal(today)
|
|
if err != nil {
|
|
slog.Error("holidays: hebcal fetch failed", "err", err)
|
|
} else {
|
|
allHolidays = append(allHolidays, holidays...)
|
|
}
|
|
|
|
// Fetch Islamic date info from Aladhan
|
|
islamicInfo, err := p.fetchAladhan(today)
|
|
if err != nil {
|
|
slog.Error("holidays: aladhan fetch failed", "err", err)
|
|
} else if islamicInfo != "" {
|
|
allHolidays = append(allHolidays, Holiday{
|
|
Name: "Islamic Date",
|
|
Description: islamicInfo,
|
|
Country: "International",
|
|
Type: "islamic-calendar",
|
|
Date: dateStr,
|
|
})
|
|
}
|
|
|
|
// Deduplicate by holiday name (case-insensitive)
|
|
allHolidays = dedupeHolidays(allHolidays)
|
|
|
|
data := holidaysDayData{Holidays: allHolidays}
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return fmt.Errorf("holidays: marshal: %w", err)
|
|
}
|
|
|
|
_, err = d.Exec(
|
|
`INSERT INTO holidays_log (date, data, posted) VALUES (?, ?, 0)
|
|
ON CONFLICT(date) DO UPDATE SET data = ?`,
|
|
dateStr, string(jsonData), string(jsonData),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("holidays: store: %w", err)
|
|
}
|
|
|
|
slog.Info("holidays: prefetched", "date", dateStr, "count", len(allHolidays))
|
|
return nil
|
|
}
|
|
|
|
// PostHolidays posts today's holidays to the given room.
|
|
func (p *HolidaysPlugin) PostHolidays(roomID id.RoomID) error {
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
d := db.Get()
|
|
|
|
var dataStr string
|
|
var posted int
|
|
err := d.QueryRow(
|
|
`SELECT data, posted FROM holidays_log WHERE date = ?`, today,
|
|
).Scan(&dataStr, &posted)
|
|
if err == sql.ErrNoRows {
|
|
slog.Warn("holidays: no entry for today, attempting prefetch", "date", today)
|
|
if err := p.Prefetch(); err != nil {
|
|
return fmt.Errorf("holidays: prefetch failed: %w", err)
|
|
}
|
|
err = d.QueryRow(
|
|
`SELECT data, posted FROM holidays_log WHERE date = ?`, today,
|
|
).Scan(&dataStr, &posted)
|
|
if err != nil {
|
|
return fmt.Errorf("holidays: still no entry after prefetch: %w", err)
|
|
}
|
|
} else if err != nil {
|
|
return fmt.Errorf("holidays: query: %w", err)
|
|
}
|
|
|
|
if posted == 1 {
|
|
slog.Info("holidays: already posted today", "date", today)
|
|
return nil
|
|
}
|
|
|
|
var data holidaysDayData
|
|
if err := json.Unmarshal([]byte(dataStr), &data); err != nil {
|
|
return fmt.Errorf("holidays: unmarshal: %w", err)
|
|
}
|
|
|
|
if len(data.Holidays) == 0 {
|
|
slog.Info("holidays: no holidays to post today")
|
|
return nil
|
|
}
|
|
|
|
msg := p.formatHolidays(today, data.Holidays)
|
|
if err := p.SendMessage(roomID, msg); err != nil {
|
|
return fmt.Errorf("holidays: send: %w", err)
|
|
}
|
|
|
|
_, err = d.Exec(`UPDATE holidays_log SET posted = 1 WHERE date = ?`, today)
|
|
if err != nil {
|
|
slog.Error("holidays: mark posted", "err", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *HolidaysPlugin) handleHolidays(ctx MessageContext) error {
|
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "holidays"))
|
|
|
|
switch strings.ToLower(args) {
|
|
case "week":
|
|
return p.handleHolidaysRange(ctx, 7)
|
|
case "month":
|
|
return p.handleHolidaysRange(ctx, 30)
|
|
default:
|
|
return p.handleHolidaysToday(ctx)
|
|
}
|
|
}
|
|
|
|
func (p *HolidaysPlugin) handleHolidaysToday(ctx MessageContext) error {
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
d := db.Get()
|
|
|
|
var dataStr string
|
|
err := d.QueryRow(`SELECT data FROM holidays_log WHERE date = ?`, today).Scan(&dataStr)
|
|
if err == sql.ErrNoRows {
|
|
// Prefetch on demand
|
|
if pfErr := p.Prefetch(); pfErr != nil {
|
|
slog.Error("holidays: on-demand prefetch failed", "err", pfErr)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch holiday data. Try again later.")
|
|
}
|
|
err = d.QueryRow(`SELECT data FROM holidays_log WHERE date = ?`, today).Scan(&dataStr)
|
|
}
|
|
if err != nil {
|
|
slog.Error("holidays: query", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch holiday data.")
|
|
}
|
|
|
|
var data holidaysDayData
|
|
if err := json.Unmarshal([]byte(dataStr), &data); err != nil {
|
|
slog.Error("holidays: unmarshal", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse holiday data.")
|
|
}
|
|
|
|
if len(data.Holidays) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No holidays today.")
|
|
}
|
|
|
|
msg := p.formatHolidays(today, data.Holidays)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
|
}
|
|
|
|
func (p *HolidaysPlugin) handleHolidaysRange(ctx MessageContext, days int) error {
|
|
d := db.Get()
|
|
now := time.Now().UTC()
|
|
|
|
var sb strings.Builder
|
|
label := "This Week"
|
|
if days == 30 {
|
|
label = "This Month"
|
|
}
|
|
sb.WriteString(fmt.Sprintf("Holidays — %s\n", label))
|
|
|
|
found := false
|
|
for i := 0; i < days; i++ {
|
|
date := now.AddDate(0, 0, i)
|
|
dateStr := date.Format("2006-01-02")
|
|
|
|
var dataStr string
|
|
err := d.QueryRow(`SELECT data FROM holidays_log WHERE date = ?`, dateStr).Scan(&dataStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var data holidaysDayData
|
|
if json.Unmarshal([]byte(dataStr), &data) != nil || len(data.Holidays) == 0 {
|
|
continue
|
|
}
|
|
|
|
found = true
|
|
sb.WriteString(fmt.Sprintf("\n%s (%s):\n", dateStr, date.Format("Monday")))
|
|
for _, h := range p.selectFeatured(data.Holidays) {
|
|
sb.WriteString(fmt.Sprintf(" - %s", h.Name))
|
|
if h.Country != "" && h.Country != "International" {
|
|
sb.WriteString(fmt.Sprintf(" [%s]", h.Country))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No holiday data available for that range.")
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *HolidaysPlugin) formatHolidays(date string, holidays []Holiday) string {
|
|
var sb strings.Builder
|
|
t, _ := time.Parse("2006-01-02", date)
|
|
sb.WriteString(fmt.Sprintf("Today's Holidays — %s\n", t.Format("Monday, January 2, 2006")))
|
|
|
|
featured := p.selectFeatured(holidays)
|
|
for _, h := range featured {
|
|
sb.WriteString(fmt.Sprintf("\n- %s", h.Name))
|
|
if h.Country != "" && h.Country != "International" {
|
|
sb.WriteString(fmt.Sprintf(" [%s]", h.Country))
|
|
}
|
|
if h.Description != "" {
|
|
sb.WriteString(fmt.Sprintf("\n %s", h.Description))
|
|
}
|
|
}
|
|
|
|
remaining := len(holidays) - len(featured)
|
|
if remaining > 0 {
|
|
sb.WriteString(fmt.Sprintf("\n\n...and %d more. Use !holidays for the full list.", remaining))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// selectFeatured picks the most interesting holidays to highlight (up to 8).
|
|
func (p *HolidaysPlugin) selectFeatured(holidays []Holiday) []Holiday {
|
|
if len(holidays) <= 8 {
|
|
return holidays
|
|
}
|
|
|
|
// Prioritize national holidays and religious observances
|
|
priorityTypes := map[string]bool{
|
|
"national": true, "public": true, "religious": true,
|
|
"observance": true, "jewish": true, "islamic-calendar": true,
|
|
}
|
|
|
|
var featured, rest []Holiday
|
|
for _, h := range holidays {
|
|
if priorityTypes[strings.ToLower(h.Type)] {
|
|
featured = append(featured, h)
|
|
} else {
|
|
rest = append(rest, h)
|
|
}
|
|
}
|
|
|
|
// Fill remaining slots
|
|
for _, h := range rest {
|
|
if len(featured) >= 8 {
|
|
break
|
|
}
|
|
featured = append(featured, h)
|
|
}
|
|
|
|
return featured
|
|
}
|
|
|
|
// normalizeHolidayName strips common suffixes like "Day", "Eve" for dedup matching.
|
|
func normalizeHolidayName(name string) string {
|
|
n := strings.ToLower(strings.TrimSpace(name))
|
|
n = strings.TrimSuffix(n, " day")
|
|
n = strings.TrimSuffix(n, "'s")
|
|
n = strings.TrimSuffix(n, "s")
|
|
return n
|
|
}
|
|
|
|
// dedupeHolidays removes duplicate holidays by name or description.
|
|
func dedupeHolidays(holidays []Holiday) []Holiday {
|
|
seenName := make(map[string]int) // normalized name -> index in result
|
|
seenDesc := make(map[string]int) // lowercase description -> index in result
|
|
|
|
var result []Holiday
|
|
for _, h := range holidays {
|
|
nameLower := normalizeHolidayName(h.Name)
|
|
descLower := strings.ToLower(h.Description)
|
|
|
|
// Dedupe by name
|
|
if idx, ok := seenName[nameLower]; ok {
|
|
result[idx].Country = "International"
|
|
continue
|
|
}
|
|
|
|
// Dedupe by description (catches "Eight Hours Day" == "Labour Day" etc.)
|
|
if descLower != "" {
|
|
if idx, ok := seenDesc[descLower]; ok {
|
|
result[idx].Country = "International"
|
|
continue
|
|
}
|
|
}
|
|
|
|
seenName[nameLower] = len(result)
|
|
if descLower != "" {
|
|
seenDesc[descLower] = len(result)
|
|
}
|
|
result = append(result, h)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (p *HolidaysPlugin) fetchCalendarific(date time.Time, country string) ([]Holiday, error) {
|
|
url := fmt.Sprintf(
|
|
"https://calendarific.com/api/v2/holidays?api_key=%s&country=%s&year=%d&month=%d&day=%d",
|
|
p.calendarificKey, country, date.Year(), int(date.Month()), date.Day(),
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("calendarific returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var calResp calendarificResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&calResp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Types to exclude — too regional to be interesting
|
|
skipTypes := map[string]bool{
|
|
"Local holiday": true,
|
|
"Common local holiday": true,
|
|
"Local observance": true,
|
|
"Clock change/Daylight Saving Time": true,
|
|
}
|
|
|
|
var holidays []Holiday
|
|
for _, h := range calResp.Response.Holidays {
|
|
hType := "other"
|
|
if len(h.Type) > 0 {
|
|
hType = h.Type[0]
|
|
}
|
|
if skipTypes[hType] {
|
|
continue
|
|
}
|
|
holidays = append(holidays, Holiday{
|
|
Name: h.Name,
|
|
Description: h.Description,
|
|
Country: h.Country.Name,
|
|
Type: hType,
|
|
Date: date.Format("2006-01-02"),
|
|
})
|
|
}
|
|
|
|
return holidays, nil
|
|
}
|
|
|
|
func (p *HolidaysPlugin) fetchHebCal(date time.Time) ([]Holiday, error) {
|
|
url := fmt.Sprintf(
|
|
"https://www.hebcal.com/hebcal?v=1&cfg=json&year=%d&month=%d",
|
|
date.Year(), int(date.Month()),
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("hebcal returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var hebResp hebcalResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&hebResp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dateStr := date.Format("2006-01-02")
|
|
var holidays []Holiday
|
|
for _, item := range hebResp.Items {
|
|
// HebCal dates are in YYYY-MM-DD format; match today
|
|
if len(item.Date) >= 10 && item.Date[:10] == dateStr {
|
|
holidays = append(holidays, Holiday{
|
|
Name: item.Title,
|
|
Description: item.Memo,
|
|
Country: "International",
|
|
Type: "jewish",
|
|
Date: dateStr,
|
|
})
|
|
}
|
|
}
|
|
|
|
return holidays, nil
|
|
}
|
|
|
|
func (p *HolidaysPlugin) fetchAladhan(date time.Time) (string, error) {
|
|
url := fmt.Sprintf(
|
|
"https://api.aladhan.com/v1/gToH/%02d-%02d-%d",
|
|
date.Day(), int(date.Month()), date.Year(),
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("aladhan returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var aResp aladhanResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&aResp); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
hijri := aResp.Data.Hijri
|
|
if hijri.Day != "" && hijri.Month.En != "" && hijri.Year != "" {
|
|
return fmt.Sprintf("%s %s, %s AH", hijri.Day, hijri.Month.En, hijri.Year), nil
|
|
}
|
|
|
|
return "", nil
|
|
}
|