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 }