diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 9f111e5..c1d93fb 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -5,6 +5,7 @@ import ( "velox-bot/internal/commands/help" cmdlevel "velox-bot/internal/commands/level" "velox-bot/internal/commands/meeting" + "velox-bot/internal/commands/timezone" "velox-bot/internal/commands/schedule" "velox-bot/internal/commands/projects" @@ -25,6 +26,7 @@ var AllCommands = []*discordgo.ApplicationCommand{ cmdlevel.Leaderboard, cmdlevel.Rewards, meeting.Meeting, + timezone.Timezone, schedule.Schedule, projects.Projects, } @@ -43,6 +45,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre "leaderboard": cmdlevel.LeaderboardHandler, "rewards": cmdlevel.RewardsHandler, "meeting": meeting.MeetingHandler, + "timezone": timezone.TimezoneHandler, "schedule": schedule.ScheduleHandler, "projects": projects.ProjectsHandler, } diff --git a/internal/commands/schedule/public/schedule.go b/internal/commands/schedule/public/schedule.go index 768fd55..e8a6c9f 100644 --- a/internal/commands/schedule/public/schedule.go +++ b/internal/commands/schedule/public/schedule.go @@ -134,19 +134,6 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { return } - whenStr = strings.TrimSpace(whenStr) - if whenStr == "" { - shared.RespondEphemeral(s, i, "Datetime cannot be empty.") - return - } - - // Expect layout "2006-01-02 15:04" in UTC. - when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, time.UTC) - if err != nil { - shared.RespondEphemeral(s, i, "Invalid datetime format. Use `2006-01-02 15:04` in UTC.") - return - } - requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) if err != nil { shared.RespondEphemeral(s, i, "Invalid user ID.") @@ -158,6 +145,26 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { return } + whenStr = strings.TrimSpace(whenStr) + if whenStr == "" { + shared.RespondEphemeral(s, i, "Datetime cannot be empty.") + return + } + + // Resolve requester's timezone and parse input using explicit layout in that zone. + loc, zone, _, err := services.Global.UserSettings.GetTimezone(context.Background(), requesterID) + if err != nil { + loc = time.UTC + zone = "UTC" + } + // Expect layout "2006-01-02 15:04" in the user's timezone. + when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").") + return + } + when = when.In(time.UTC) + ctx := context.Background() scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc) if err != nil { @@ -176,11 +183,23 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { Title: "New session request", Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID), Color: 0x4caf50, - Fields: []*discordgo.MessageEmbedField{ - { - Name: "When (UTC)", - Value: when.Format("2006-01-02 15:04"), - }, + } + // Show both UTC and invitee-local time if available. + utcStr := when.Format("2006-01-02 15:04") + locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), inviteeID) + if err != nil { + locInv = time.UTC + zoneInv = "UTC" + } + localInvStr := when.In(locInv).Format("2006-01-02 15:04") + embed.Fields = []*discordgo.MessageEmbedField{ + { + Name: "When (UTC)", + Value: utcStr, + }, + { + Name: "When (" + zoneInv + ")", + Value: localInvStr, }, } if desc != "" { @@ -298,12 +317,6 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { return } - when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, time.UTC) - if err != nil { - shared.RespondEphemeral(s, i, "Invalid datetime format. Use `2006-01-02 15:04` in UTC.") - return - } - userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) if err != nil { shared.RespondEphemeral(s, i, "Invalid user ID.") @@ -325,6 +338,20 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { return } + // Use rescheduler's timezone for parsing. + loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID) + if err != nil { + loc = time.UTC + zone = "UTC" + } + // Expect explicit layout "2006-01-02 15:04" in user's timezone. + when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").") + return + } + when = when.In(time.UTC) + if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil { shared.RespondEphemeral(s, i, "Failed to reschedule session.") return @@ -338,12 +365,18 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { otherID = sch.RequesterID } - // DM both about the change - msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC with <@%d>.", sch.ID, whenFmt, otherID) - if sch.Description != "" { - msg += "\nTopic: " + sch.Description - } + // DM both about the change, including their local times if available. for _, uid := range []int64{sch.RequesterID, sch.InviteeID} { + locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid) + if err != nil { + locU = time.UTC + zoneU = "UTC" + } + localU := when.In(locU).Format("2006-01-02 15:04") + msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID) + if sch.Description != "" { + msg += "\nTopic: " + sch.Description + } ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10)) if err != nil { continue @@ -361,11 +394,22 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { Title: "Rescheduled session", Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID), Color: 0xffc107, - Fields: []*discordgo.MessageEmbedField{ - { - Name: "When (UTC)", - Value: whenFmt, - }, + } + // Show both UTC and invitee-local time if available. + locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID) + if errTZ != nil { + locInv = time.UTC + zoneInv = "UTC" + } + localInvStr := when.In(locInv).Format("2006-01-02 15:04") + embed.Fields = []*discordgo.MessageEmbedField{ + { + Name: "When (UTC)", + Value: whenFmt, + }, + { + Name: "When (" + zoneInv + ")", + Value: localInvStr, }, } if sch.Description != "" { diff --git a/internal/commands/timezone/public/timezone.go b/internal/commands/timezone/public/timezone.go new file mode 100644 index 0000000..e5d44d8 --- /dev/null +++ b/internal/commands/timezone/public/timezone.go @@ -0,0 +1,115 @@ +package public + +import ( + "context" + "time" + + "velox-bot/internal/commands/timezone/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Timezone = &discordgo.ApplicationCommand{ + Name: "timezone", + Description: "Configure and view your timezone", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set", + Description: "Set your timezone (IANA name, e.g., Europe/Lisbon)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "zone", + Description: "Timezone (e.g., Europe/Lisbon, America/New_York)", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "show", + Description: "Show your current timezone", + }, + }, +} + +func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireUserSettingsService(s, i) { + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + handleShow(s, i) + return + } + + switch data.Options[0].Name { + case "set": + handleSet(s, i) + case "show": + handleShow(s, i) + default: + shared.RespondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleSet(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID, ok := shared.CurrentUserID(i) + if !ok { + shared.RespondEphemeral(s, i, "Could not determine your user ID.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing options.") + return + } + opt := data.Options[0] + + var zone string + for _, o := range opt.Options { + if o.Name == "zone" { + zone = o.StringValue() + } + } + if zone == "" { + shared.RespondEphemeral(s, i, "Timezone cannot be empty.") + return + } + + // Validate via service (which uses time.LoadLocation). + if err := services.Global.UserSettings.SetTimezone(context.Background(), userID, zone); err != nil { + shared.RespondEphemeral(s, i, "Invalid timezone. Use an IANA name like `Europe/Lisbon`.") + return + } + + loc, z, _, _ := services.Global.UserSettings.GetTimezone(context.Background(), userID) + now := time.Now().In(loc).Format("2006-01-02 15:04") + shared.RespondEphemeral(s, i, "Your timezone is now set to `"+z+"` (current local time: "+now+").") +} + +func handleShow(s *discordgo.Session, i *discordgo.InteractionCreate) { + userID, ok := shared.CurrentUserID(i) + if !ok { + shared.RespondEphemeral(s, i, "Could not determine your user ID.") + return + } + + loc, z, userDefined, err := services.Global.UserSettings.GetTimezone(context.Background(), userID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load your timezone.") + return + } + now := time.Now().In(loc).Format("2006-01-02 15:04") + + if userDefined { + shared.RespondEphemeral(s, i, "Your timezone is `"+z+"` (current local time: "+now+").") + } else { + shared.RespondEphemeral(s, i, "You don't have a timezone set. Using `UTC` (current time: "+now+"). Use `/timezone set` to configure one.") + } +} + diff --git a/internal/commands/timezone/registry.go b/internal/commands/timezone/registry.go new file mode 100644 index 0000000..7bdedd7 --- /dev/null +++ b/internal/commands/timezone/registry.go @@ -0,0 +1,14 @@ +package timezone + +import ( + "velox-bot/internal/commands/timezone/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Timezone *discordgo.ApplicationCommand = public.Timezone +) + +func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TimezoneHandler(s, i) } + diff --git a/internal/commands/timezone/shared/shared.go b/internal/commands/timezone/shared/shared.go new file mode 100644 index 0000000..257e915 --- /dev/null +++ b/internal/commands/timezone/shared/shared.go @@ -0,0 +1,45 @@ +package shared + +import ( + "strconv" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func RequireUserSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.UserSettings == nil { + RespondEphemeral(s, i, "User settings service is not available.") + return false + } + return true +} + +func CurrentUserID(i *discordgo.InteractionCreate) (int64, bool) { + var idStr string + if i.Member != nil && i.Member.User != nil { + idStr = i.Member.User.ID + } else if i.User != nil { + idStr = i.User.ID + } + if idStr == "" { + return 0, false + } + uid, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return 0, false + } + return uid, true +} + diff --git a/internal/db/repos/usersettingsrepo/repo.go b/internal/db/repos/usersettingsrepo/repo.go new file mode 100644 index 0000000..364f77f --- /dev/null +++ b/internal/db/repos/usersettingsrepo/repo.go @@ -0,0 +1,44 @@ +package usersettingsrepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) SetTimezone(ctx context.Context, userID int64, zone string) error { + const q = ` + INSERT INTO user_settings (user_id, timezone) + VALUES ($1, $2) + ON CONFLICT (user_id) + DO UPDATE SET timezone = EXCLUDED.timezone + ` + _, err := r.db.ExecContext(ctx, q, userID, zone) + return err +} + +func (r *Repo) GetTimezone(ctx context.Context, userID int64) (string, bool, error) { + const q = ` + SELECT timezone + FROM user_settings + WHERE user_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, userID) + var zone string + switch err := row.Scan(&zone); err { + case nil: + return zone, true, nil + case sql.ErrNoRows: + return "", false, nil + default: + return "", false, err + } +} + diff --git a/internal/db/services/services.go b/internal/db/services/services.go index b1ff8cc..62ce89d 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -5,6 +5,7 @@ import ( "velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/meeting" "velox-bot/internal/db/services/schedule" + "velox-bot/internal/db/services/usersettings" "velox-bot/internal/db/services/projects" "velox-bot/internal/db/services/rps" ) @@ -14,18 +15,20 @@ type Services struct { LevelSettings *levelsettings.Service Meeting *meeting.Service Schedule *schedule.Service + UserSettings *usersettings.Service Projects *projects.Service RPS *rps.Service } var Global *Services -func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, projects *projects.Service, rps *rps.Service) *Services { +func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service) *Services { s := &Services{ Level: level, LevelSettings: levelSettings, Meeting: meeting, Schedule: schedule, + UserSettings: userSettings, Projects: projects, RPS: rps, } diff --git a/internal/db/services/usersettings/service.go b/internal/db/services/usersettings/service.go new file mode 100644 index 0000000..a4b80b9 --- /dev/null +++ b/internal/db/services/usersettings/service.go @@ -0,0 +1,42 @@ +package usersettings + +import ( + "context" + "time" + + "velox-bot/internal/db/repos/usersettingsrepo" +) + +type Service struct { + repo *usersettingsrepo.Repo +} + +func New(repo *usersettingsrepo.Repo) *Service { + return &Service{repo: repo} +} + +func (s *Service) SetTimezone(ctx context.Context, userID int64, zone string) error { + // validate zone before storing + if _, err := time.LoadLocation(zone); err != nil { + return err + } + return s.repo.SetTimezone(ctx, userID, zone) +} + +// GetTimezone returns the user's location (or UTC) and the zone string and a flag indicating if it was user-defined. +func (s *Service) GetTimezone(ctx context.Context, userID int64) (*time.Location, string, bool, error) { + zone, ok, err := s.repo.GetTimezone(ctx, userID) + if err != nil { + return time.UTC, "UTC", false, err + } + if !ok || zone == "" { + return time.UTC, "UTC", false, nil + } + loc, err := time.LoadLocation(zone) + if err != nil { + // fallback to UTC but indicate not user-defined + return time.UTC, "UTC", false, nil + } + return loc, zone, true, nil +} + diff --git a/internal/events/schedule_reactions.go b/internal/events/schedule_reactions.go index c93f6b8..f0aca0e 100644 --- a/internal/events/schedule_reactions.go +++ b/internal/events/schedule_reactions.go @@ -64,24 +64,32 @@ func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReaction return } - // Notify requester via DM. - requesterID := strconv.FormatInt(sch.RequesterID, 10) - dmCh, err := s.UserChannelCreate(requesterID) - if err != nil { - return - } - statusText := map[schedulerepo.ScheduleStatus]string{ schedulerepo.StatusAccepted: "accepted ✅", schedulerepo.StatusDeclined: "declined ❌", schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁", }[newStatus] - content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " + - sch.ScheduledAt.UTC().Format("2006-01-02 15:04") + " UTC has been " + statusText + "." + utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") - if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil { - log.Printf("schedule: failed to DM requester about status change: %v", err) + // Notify requester with local time if configured. + if svc.UserSettings != nil { + loc, zone, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID) + if err != nil { + loc = sch.ScheduledAt.UTC().Location() + zone = "UTC" + } + localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04") + content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " + + utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "." + + requesterID := strconv.FormatInt(sch.RequesterID, 10) + dmCh, err := s.UserChannelCreate(requesterID) + if err == nil { + if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil { + log.Printf("schedule: failed to DM requester about status change: %v", err) + } + } } } diff --git a/internal/events/schedule_reminders.go b/internal/events/schedule_reminders.go index 2d7cb18..dac4751 100644 --- a/internal/events/schedule_reminders.go +++ b/internal/events/schedule_reminders.go @@ -47,26 +47,41 @@ func StartScheduleReminderLoop(s *discordgo.Session, svc *services.Services) { continue } - // Build reminder text - whenStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") - base := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC with <@%d>.", whenStr, sch.InviteeID) + utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") desc := sch.Description - if desc != "" { - base += "\nTopic: " + desc + + // Notify requester with local time if available. + if svc.UserSettings != nil { + locReq, zoneReq, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID) + if err != nil { + locReq = sch.ScheduledAt.UTC().Location() + zoneReq = "UTC" + } + localReq := sch.ScheduledAt.In(locReq).Format("2006-01-02 15:04") + base := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localReq, zoneReq, sch.InviteeID) + if desc != "" { + base += "\nTopic: " + desc + } + if err := dmUser(s, sch.RequesterID, base); err != nil { + log.Printf("schedule: failed to DM requester reminder: %v", err) + } } - // Notify requester - if err := dmUser(s, sch.RequesterID, base); err != nil { - log.Printf("schedule: failed to DM requester reminder: %v", err) - } - - // Notify invitee (mirror text with requester mention) - baseInvitee := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC with <@%d>.", whenStr, sch.RequesterID) - if desc != "" { - baseInvitee += "\nTopic: " + desc - } - if err := dmUser(s, sch.InviteeID, baseInvitee); err != nil { - log.Printf("schedule: failed to DM invitee reminder: %v", err) + // Notify invitee with their local time if available. + if svc.UserSettings != nil { + locInv, zoneInv, _, err := svc.UserSettings.GetTimezone(ctx, sch.InviteeID) + if err != nil { + locInv = sch.ScheduledAt.UTC().Location() + zoneInv = "UTC" + } + localInv := sch.ScheduledAt.In(locInv).Format("2006-01-02 15:04") + baseInvitee := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localInv, zoneInv, sch.RequesterID) + if desc != "" { + baseInvitee += "\nTopic: " + desc + } + if err := dmUser(s, sch.InviteeID, baseInvitee); err != nil { + log.Printf("schedule: failed to DM invitee reminder: %v", err) + } } if err := svc.Schedule.MarkReminded(ctx, sch.ID); err != nil { diff --git a/main.go b/main.go index 3d0f526..fcb9467 100644 --- a/main.go +++ b/main.go @@ -15,11 +15,13 @@ import ( "velox-bot/internal/db/repos/rpsrepo" "velox-bot/internal/db/repos/schedulerepo" "velox-bot/internal/db/repos/settingsrepo" + "velox-bot/internal/db/repos/usersettingsrepo" "velox-bot/internal/db/services" "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/meeting" "velox-bot/internal/db/services/schedule" + "velox-bot/internal/db/services/usersettings" "velox-bot/internal/db/services/projects" "velox-bot/internal/db/services/rps" ) @@ -43,13 +45,15 @@ func main() { rpsRepo := rpsrepo.NewRepo(db) projectsRepo := projectsrepo.NewRepo(db) scheduleRepo := schedulerepo.NewRepo(db) + userSettingsRepo := usersettingsrepo.NewRepo(db) levelService := level.New(levelRepo, settingsRepo) levelSettingsService := levelsettings.New(settingsRepo) meetingService := meeting.New(settingsRepo) scheduleService := schedule.New(scheduleRepo) + userSettingsService := usersettings.New(userSettingsRepo) projectsService := projects.New(projectsRepo) rpsService := rps.New(rpsRepo) - services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, projectsService, rpsService) + services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services) if err != nil { diff --git a/schema.sql b/schema.sql index 193ae77..89b5597 100644 --- a/schema.sql +++ b/schema.sql @@ -122,3 +122,8 @@ CREATE TABLE IF NOT EXISTS schedule_messages ( is_dm BOOLEAN NOT NULL DEFAULT TRUE, PRIMARY KEY (schedule_id, message_id) ); + +CREATE TABLE IF NOT EXISTS user_settings ( + user_id BIGINT PRIMARY KEY, + timezone TEXT NOT NULL +);