package plugin import ( "database/sql" "fmt" "log/slog" "strconv" "strings" "time" "gogobee/internal/db" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" ) // BirthdayPlugin manages user birthdays with reminders and announcements. type BirthdayPlugin struct { Base xp *XPPlugin euro *EuroPlugin } // NewBirthdayPlugin creates a new BirthdayPlugin. func NewBirthdayPlugin(client *mautrix.Client, xp *XPPlugin, euro *EuroPlugin) *BirthdayPlugin { return &BirthdayPlugin{ Base: NewBase(client), xp: xp, euro: euro, } } func (p *BirthdayPlugin) Name() string { return "birthday" } func (p *BirthdayPlugin) Commands() []CommandDef { return []CommandDef{ {Name: "birthday", Description: "Set, show, or remove your birthday", Usage: "!birthday set | !birthday set | !birthday show | !birthday remove", Category: "Personal"}, {Name: "birthdays", Description: "Show upcoming birthdays (next 30 days)", Usage: "!birthdays", Category: "Personal"}, } } func (p *BirthdayPlugin) Init() error { return nil } func (p *BirthdayPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *BirthdayPlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "birthdays") { return p.handleUpcoming(ctx) } if p.IsCommand(ctx.Body, "birthday") { return p.handleBirthday(ctx) } return nil } func (p *BirthdayPlugin) handleBirthday(ctx MessageContext) error { args := strings.TrimSpace(p.GetArgs(ctx.Body, "birthday")) parts := strings.SplitN(args, " ", 2) if len(parts) == 0 || parts[0] == "" { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !birthday set | !birthday show | !birthday remove") } sub := strings.ToLower(parts[0]) switch sub { case "set": if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !birthday set or !birthday set ") } return p.handleSet(ctx, strings.TrimSpace(parts[1])) case "show": return p.handleShow(ctx) case "remove": return p.handleRemove(ctx) default: return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !birthday set | !birthday show | !birthday remove") } } func (p *BirthdayPlugin) handleSet(ctx MessageContext, dateStr string) error { month, day, year, err := parseBirthdayDate(dateStr) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid date format: %s. Use MM-DD or MM-DD-YYYY.", err.Error())) } // Validate the date makes sense if month < 1 || month > 12 || day < 1 || day > 31 { return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid date. Month must be 1-12 and day must be 1-31.") } // Validate day for the given month if year > 0 { t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) if t.Month() != time.Month(month) { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid date: %s %d doesn't have %d days.", time.Month(month), year, day)) } } d := db.Get() _, err = d.Exec( `INSERT INTO birthdays (user_id, month, day, year) VALUES (?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET month = ?, day = ?, year = ?`, string(ctx.Sender), month, day, year, month, day, year, ) if err != nil { slog.Error("birthday: set", "err", err) return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save your birthday.") } display := fmt.Sprintf("%s %d", time.Month(month), day) if year > 0 { display += fmt.Sprintf(", %d", year) } return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Birthday set to %s!", display)) } func (p *BirthdayPlugin) handleShow(ctx MessageContext) error { d := db.Get() var month, day, year int err := d.QueryRow( `SELECT month, day, year FROM birthdays WHERE user_id = ?`, string(ctx.Sender), ).Scan(&month, &day, &year) if err == sql.ErrNoRows { return p.SendReply(ctx.RoomID, ctx.EventID, "You haven't set your birthday yet. Use !birthday set .") } if err != nil { slog.Error("birthday: show", "err", err) return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch your birthday.") } display := fmt.Sprintf("%s %d", time.Month(month), day) if year > 0 { display += fmt.Sprintf(", %d", year) } return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Your birthday: %s", display)) } func (p *BirthdayPlugin) handleRemove(ctx MessageContext) error { d := db.Get() res, err := d.Exec(`DELETE FROM birthdays WHERE user_id = ?`, string(ctx.Sender)) if err != nil { slog.Error("birthday: remove", "err", err) return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove your birthday.") } n, _ := res.RowsAffected() if n == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, "You don't have a birthday set.") } return p.SendReply(ctx.RoomID, ctx.EventID, "Birthday removed.") } func (p *BirthdayPlugin) handleUpcoming(ctx MessageContext) error { d := db.Get() now := time.Now().UTC() rows, err := d.Query(`SELECT user_id, month, day, year FROM birthdays ORDER BY month, day`) if err != nil { slog.Error("birthday: upcoming query", "err", err) return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch upcoming birthdays.") } defer rows.Close() type bdayEntry struct { UserID string Month int Day int Year int DaysTo int } var upcoming []bdayEntry for rows.Next() { var userID string var month, day, year int if err := rows.Scan(&userID, &month, &day, &year); err != nil { continue } daysTo := daysUntilBirthday(now, month, day) if daysTo <= 30 { upcoming = append(upcoming, bdayEntry{ UserID: userID, Month: month, Day: day, Year: year, DaysTo: daysTo, }) } } if len(upcoming) == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, "No upcoming birthdays in the next 30 days.") } // Sort by days until birthday for i := 0; i < len(upcoming); i++ { for j := i + 1; j < len(upcoming); j++ { if upcoming[j].DaysTo < upcoming[i].DaysTo { upcoming[i], upcoming[j] = upcoming[j], upcoming[i] } } } var sb strings.Builder sb.WriteString("Upcoming Birthdays (next 30 days):\n") for _, b := range upcoming { display := fmt.Sprintf("%s %d", time.Month(b.Month), b.Day) if b.Year > 0 { age := now.Year() - b.Year // Adjust if birthday hasn't happened yet this year bdayThisYear := time.Date(now.Year(), time.Month(b.Month), b.Day, 0, 0, 0, 0, time.UTC) if now.Before(bdayThisYear) { age-- } display += fmt.Sprintf(" (turning %d)", age+1) } when := "TODAY!" if b.DaysTo == 1 { when = "tomorrow" } else if b.DaysTo > 1 { when = fmt.Sprintf("in %d days", b.DaysTo) } sb.WriteString(fmt.Sprintf(" - %s: %s — %s\n", b.UserID, display, when)) } return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } // CheckAndPost checks for birthdays today and posts announcements. func (p *BirthdayPlugin) CheckAndPost(roomID id.RoomID) error { now := time.Now().UTC() month := int(now.Month()) day := now.Day() year := now.Year() d := db.Get() // Collect all birthday matches first to avoid nested queries on a single SQLite connection type bdayMatch struct { userID string birthYear int } rows, err := d.Query( `SELECT user_id, year FROM birthdays WHERE month = ? AND day = ?`, month, day, ) if err != nil { return fmt.Errorf("birthday: check query: %w", err) } var matches []bdayMatch for rows.Next() { var m bdayMatch if err := rows.Scan(&m.userID, &m.birthYear); err != nil { slog.Error("birthday: scan", "err", err) continue } matches = append(matches, m) } rows.Close() if err := rows.Err(); err != nil { return fmt.Errorf("birthday: rows iteration: %w", err) } for _, m := range matches { // Check if already announced in THIS room this year roomKey := fmt.Sprintf("%s:%d:%s", m.userID, year, roomID) if db.JobCompleted("birthday", roomKey) { continue } // Build announcement var ageStr string if m.birthYear > 0 { age := year - m.birthYear ageStr = fmt.Sprintf(" They're turning %d!", age) } announcement := fmt.Sprintf("Happy Birthday to %s!%s Have an amazing day! Everyone gets 10 XP and €100 to celebrate!", m.userID, ageStr) if err := p.SendMessage(roomID, announcement); err != nil { slog.Error("birthday: send announcement", "user", m.userID, "err", err) continue } db.MarkJobCompleted("birthday", roomKey) // XP grant and DM only once per user per year (not per room) var fired int err := d.QueryRow( `SELECT 1 FROM birthday_fired WHERE user_id = ? AND year = ?`, m.userID, year, ).Scan(&fired) if err == nil { continue // Already granted XP and sent DM } // Grant 1,000 euros if p.euro != nil { p.euro.Credit(id.UserID(m.userID), 1000, "birthday gift") } // Send DM to the birthday person dmMsg := fmt.Sprintf("Happy Birthday! The community wishes you a wonderful day!%s You've been granted 100 bonus XP and €1,000 as a birthday gift!", ageStr) if err := p.SendDM(id.UserID(m.userID), dmMsg); err != nil { slog.Error("birthday: send DM", "user", m.userID, "err", err) } // Grant 100 XP if p.xp != nil { p.xp.GrantXP(id.UserID(m.userID), 100, "birthday") } else { p.grantBirthdayXP(id.UserID(m.userID), 100) } // Grant 10 XP (10% of birthday award) to every other user as a community celebration p.grantCommunityBirthdayXP(m.userID) // Mark as fired (for XP/DM dedup) db.Exec("birthday: mark fired", `INSERT INTO birthday_fired (user_id, year) VALUES (?, ?) ON CONFLICT(user_id, year) DO NOTHING`, m.userID, year, ) } return nil } // grantBirthdayXP is a fallback that inserts XP directly via SQL if no XPPlugin is available. func (p *BirthdayPlugin) grantBirthdayXP(userID id.UserID, amount int) { d := db.Get() _, err := d.Exec( `INSERT INTO users (user_id, xp, level, last_xp_at) VALUES (?, 0, 0, 0) ON CONFLICT(user_id) DO NOTHING`, string(userID)) if err != nil { slog.Error("birthday: ensure user", "err", err) return } _, err = d.Exec( `UPDATE users SET xp = xp + ?, last_xp_at = ? WHERE user_id = ?`, amount, time.Now().UTC().Unix(), string(userID)) if err != nil { slog.Error("birthday: grant xp", "err", err) return } db.Exec("birthday: log xp", `INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`, string(userID), amount, "birthday") } // grantCommunityBirthdayXP awards 10 XP and €100 to every user except the birthday person. func (p *BirthdayPlugin) grantCommunityBirthdayXP(birthdayUserID string) { d := db.Get() now := time.Now().UTC().Unix() // Bulk-update XP for all users except the birthday person res, err := d.Exec( `UPDATE users SET xp = xp + 10, last_xp_at = ? WHERE user_id != ?`, now, birthdayUserID, ) if err != nil { slog.Error("birthday: community xp grant", "err", err) return } n, _ := res.RowsAffected() slog.Info("birthday: community celebration", "users", n, "xp_each", 10, "euro_each", 100) // Grant €100 to each user if p.euro != nil { rows, err := d.Query(`SELECT user_id FROM users WHERE user_id != ?`, birthdayUserID) if err != nil { slog.Error("birthday: community euro query", "err", err) return } var userIDs []string for rows.Next() { var uid string if err := rows.Scan(&uid); err != nil { continue } userIDs = append(userIDs, uid) } rows.Close() for _, uid := range userIDs { p.euro.Credit(id.UserID(uid), 100, "birthday celebration") } } } // parseBirthdayDate parses MM-DD or MM-DD-YYYY format. func parseBirthdayDate(s string) (month, day, year int, err error) { parts := strings.Split(s, "-") switch len(parts) { case 2: // MM-DD month, err = strconv.Atoi(parts[0]) if err != nil { return 0, 0, 0, fmt.Errorf("invalid month: %s", parts[0]) } day, err = strconv.Atoi(parts[1]) if err != nil { return 0, 0, 0, fmt.Errorf("invalid day: %s", parts[1]) } return month, day, 0, nil case 3: // MM-DD-YYYY month, err = strconv.Atoi(parts[0]) if err != nil { return 0, 0, 0, fmt.Errorf("invalid month: %s", parts[0]) } day, err = strconv.Atoi(parts[1]) if err != nil { return 0, 0, 0, fmt.Errorf("invalid day: %s", parts[1]) } year, err = strconv.Atoi(parts[2]) if err != nil { return 0, 0, 0, fmt.Errorf("invalid year: %s", parts[2]) } return month, day, year, nil default: return 0, 0, 0, fmt.Errorf("use MM-DD or MM-DD-YYYY format") } } // daysUntilBirthday calculates days from now until the next occurrence of the given month/day. func daysUntilBirthday(now time.Time, month, day int) int { thisYear := time.Date(now.Year(), time.Month(month), day, 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) if thisYear.Before(today) { // Birthday already passed this year, calculate for next year thisYear = time.Date(now.Year()+1, time.Month(month), day, 0, 0, 0, 0, time.UTC) } return int(thisYear.Sub(today).Hours() / 24) }