Files
gogobee/internal/plugin/holidays.go
prosolis 3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00

578 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()
// Per-room dedup
roomKey := fmt.Sprintf("%s:%s", today, roomID)
if db.JobCompleted("holidays", roomKey) {
slog.Info("holidays: already posted today", "date", today, "room", roomID)
return nil
}
var dataStr string
err := d.QueryRow(
`SELECT data FROM holidays_log WHERE date = ?`, today,
).Scan(&dataStr)
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 FROM holidays_log WHERE date = ?`, today,
).Scan(&dataStr)
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)
}
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, false)
if err := p.SendMessage(roomID, msg); err != nil {
return fmt.Errorf("holidays: send: %w", err)
}
db.MarkJobCompleted("holidays", roomKey)
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, true)
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, full bool) 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")))
shown := holidays
if !full {
shown = p.selectFeatured(holidays)
}
for _, h := range shown {
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))
}
}
if !full {
remaining := len(holidays) - len(shown)
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
}