mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
Rewrite from TypeScript to Go
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>
This commit is contained in:
574
internal/plugin/holidays.go
Normal file
574
internal/plugin/holidays.go
Normal file
@@ -0,0 +1,574 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user