diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 225c29c..5aeecd4 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -28,7 +28,13 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, // - InteractionCreate (slash commands): Guilds // - MessageCreate (leveling): GuildMessages // - VoiceStateUpdate (meeting lobby): GuildVoiceStates - session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsGuildVoiceStates + // - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions + session.Identify.Intents = discordgo.IntentsGuilds | + discordgo.IntentsGuildMessages | + discordgo.IntentsGuildVoiceStates | + discordgo.IntentsGuildMessageReactions | + discordgo.IntentsDirectMessages | + discordgo.IntentsDirectMessageReactions return &Bot{ Session: session, @@ -64,6 +70,12 @@ func (b *Bot) Start() error { events.HandleVoiceStateUpdate(s, vs, b.Services) }) + b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) { + events.HandleMessageReactionAdd(s, r, b.Services) + }) + + events.StartScheduleReminderLoop(b.Session, b.Services) + return nil } diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 717213a..c1d93fb 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -5,6 +5,8 @@ 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" "github.com/bwmarrin/discordgo" @@ -24,6 +26,8 @@ var AllCommands = []*discordgo.ApplicationCommand{ cmdlevel.Leaderboard, cmdlevel.Rewards, meeting.Meeting, + timezone.Timezone, + schedule.Schedule, projects.Projects, } @@ -41,6 +45,8 @@ 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 new file mode 100644 index 0000000..e8a6c9f --- /dev/null +++ b/internal/commands/schedule/public/schedule.go @@ -0,0 +1,439 @@ +package public + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "velox-bot/internal/commands/schedule/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Schedule = &discordgo.ApplicationCommand{ + Name: "schedule", + Description: "Schedule 1:1 sessions", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "create", + Description: "Create a new session with a user", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to invite", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "datetime", + Description: "When (UTC), format: 2006-01-02 15:04", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "description", + Description: "What is this session about? (optional)", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "list", + Description: "List your upcoming sessions", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "reschedule", + Description: "Reschedule an existing session", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "id", + Description: "ID of the session (from /schedule list)", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "datetime", + Description: "New datetime (UTC), format: 2006-01-02 15:04", + Required: true, + }, + }, + }, + }, +} + +func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) || !shared.RequireScheduleService(s, i) { + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + handleList(s, i) + return + } + + switch data.Options[0].Name { + case "create": + handleCreate(s, i) + case "list": + handleList(s, i) + case "reschedule": + handleReschedule(s, i) + default: + shared.RespondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing options.") + return + } + opt := data.Options[0] + + var ( + targetUser *discordgo.User + whenStr string + desc string + ) + for _, o := range opt.Options { + switch o.Name { + case "user": + targetUser = o.UserValue(s) + case "datetime": + whenStr = o.StringValue() + case "description": + desc = o.StringValue() + } + } + + if targetUser == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + if targetUser.ID == i.Member.User.ID { + shared.RespondEphemeral(s, i, "You cannot schedule a session with yourself.") + return + } + + requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + inviteeID, err := strconv.ParseInt(targetUser.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid target user ID.") + 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 { + shared.RespondEphemeral(s, i, "Failed to create schedule.") + return + } + + // Send DM to invitee. + dmCh, err := s.UserChannelCreate(targetUser.ID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).") + return + } + + embed := &discordgo.MessageEmbed{ + Title: "New session request", + Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID), + Color: 0x4caf50, + } + // 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 != "" { + embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ + Name: "Description", + Value: desc, + }) + } + + msg, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{ + Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request rescheduling.", + Embed: embed, + }) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to DM the invited user.") + return + } + + // Add reactions for interaction. + for _, emoji := range []string{"✅", "❌", "🔁"} { + _ = s.MessageReactionAdd(dmCh.ID, msg.ID, emoji) + } + + // Store linkage between message and schedule. + msgID64, _ := strconv.ParseInt(msg.ID, 10, 64) + chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64) + _ = services.Global.Schedule.AddMessage(ctx, scheduleID, msgID64, chID64, true) + + shared.RespondEphemeral(s, i, "Session request created and sent to "+targetUser.Mention()+".") +} + +func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return + } + + userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + const limit = 10 + items, err := services.Global.Schedule.ListForUser(context.Background(), guildID, userID, limit) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load your sessions.") + return + } + if len(items) == 0 { + shared.RespondEphemeral(s, i, "You have no upcoming sessions.") + return + } + + var b strings.Builder + for _, sch := range items { + role := "Requester" + otherID := sch.InviteeID + if sch.InviteeID == userID { + role = "Invitee" + otherID = sch.RequesterID + } + status := string(sch.Status) + whenStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") + line := fmt.Sprintf("ID `%d` • %s with <@%d> at %s UTC (%s)\n", sch.ID, role, otherID, whenStr, status) + if sch.Description != "" { + line += " " + sch.Description + "\n" + } + b.WriteString(line) + } + + shared.RespondEphemeral(s, i, b.String()) +} + +func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing options.") + return + } + opt := data.Options[0] + + var ( + idVal int64 + whenStr string + ) + for _, o := range opt.Options { + switch o.Name { + case "id": + idVal = o.IntValue() + case "datetime": + whenStr = o.StringValue() + } + } + if idVal <= 0 { + shared.RespondEphemeral(s, i, "Invalid session ID.") + return + } + whenStr = strings.TrimSpace(whenStr) + if whenStr == "" { + shared.RespondEphemeral(s, i, "Datetime cannot be empty.") + return + } + + userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + ctx := context.Background() + sch, err := services.Global.Schedule.GetByID(ctx, idVal) + if err != nil || sch == nil { + shared.RespondEphemeral(s, i, "Session not found.") + return + } + if sch.GuildID != guildID { + shared.RespondEphemeral(s, i, "This session belongs to another server.") + return + } + if userID != sch.RequesterID && userID != sch.InviteeID { + shared.RespondEphemeral(s, i, "You are not part of this session.") + 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 + } + + // Notify participants via DM and send new request DM to invitee. + whenFmt := when.Format("2006-01-02 15:04") + + otherID := sch.InviteeID + if userID == sch.InviteeID { + otherID = sch.RequesterID + } + + // 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 + } + _, _ = s.ChannelMessageSend(ch.ID, msg) + } + + // Send new "request" DM to invitee with reactions again. + inviteeStr := strconv.FormatInt(sch.InviteeID, 10) + inviteeUser, err := s.User(inviteeStr) + if err == nil && inviteeUser != nil { + dmCh, err := s.UserChannelCreate(inviteeStr) + if err == nil { + embed := &discordgo.MessageEmbed{ + Title: "Rescheduled session", + Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID), + Color: 0xffc107, + } + // 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 != "" { + embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ + Name: "Description", + Value: sch.Description, + }) + } + msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{ + Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.", + Embed: embed, + }) + if err == nil && msgObj != nil { + for _, emoji := range []string{"✅", "❌", "🔁"} { + _ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji) + } + msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64) + chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64) + _ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true) + } + } + } + + shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.") +} + + diff --git a/internal/commands/schedule/registry.go b/internal/commands/schedule/registry.go new file mode 100644 index 0000000..63d7d13 --- /dev/null +++ b/internal/commands/schedule/registry.go @@ -0,0 +1,14 @@ +package schedule + +import ( + "velox-bot/internal/commands/schedule/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Schedule *discordgo.ApplicationCommand = public.Schedule +) + +func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) } + diff --git a/internal/commands/schedule/shared/shared.go b/internal/commands/schedule/shared/shared.go new file mode 100644 index 0000000..ffc667e --- /dev/null +++ b/internal/commands/schedule/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 RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.GuildID == "" { + RespondEphemeral(s, i, "This command can only be used in a server.") + return false + } + return true +} + +func RequireScheduleService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.Schedule == nil { + RespondEphemeral(s, i, "Scheduling service is not available.") + return false + } + return true +} + +func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) { + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + RespondEphemeral(s, i, "Invalid guild ID.") + return 0, false + } + return guildID, true +} + 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/schedulerepo/repo.go b/internal/db/repos/schedulerepo/repo.go new file mode 100644 index 0000000..6b628fb --- /dev/null +++ b/internal/db/repos/schedulerepo/repo.go @@ -0,0 +1,189 @@ +package schedulerepo + +import ( + "context" + "database/sql" + "time" +) + +type Repo struct { + db *sql.DB +} + +type ScheduleStatus string + +const ( + StatusPending ScheduleStatus = "pending" + StatusAccepted ScheduleStatus = "accepted" + StatusDeclined ScheduleStatus = "declined" + StatusRescheduleRequested ScheduleStatus = "reschedule_requested" +) + +type Schedule struct { + ID int64 + GuildID int64 + RequesterID int64 + InviteeID int64 + ScheduledAt time.Time + Status ScheduleStatus + Description string + ReminderSent bool + CreatedAt time.Time +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) { + const q = ` + INSERT INTO schedules (guild_id, requester_id, invitee_id, scheduled_at, description) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + ` + var id int64 + if err := r.db.QueryRowContext(ctx, q, guildID, requesterID, inviteeID, when, description).Scan(&id); err != nil { + return 0, err + } + return id, nil +} + +func (r *Repo) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error { + const q = ` + INSERT INTO schedule_messages (schedule_id, message_id, channel_id, is_dm) + VALUES ($1, $2, $3, $4) + ON CONFLICT (schedule_id, message_id) DO NOTHING + ` + _, err := r.db.ExecContext(ctx, q, scheduleID, messageID, channelID, isDM) + return err +} + +func (r *Repo) GetByMessageID(ctx context.Context, messageID int64) (*Schedule, error) { + const q = ` + SELECT s.id, s.guild_id, s.requester_id, s.invitee_id, s.scheduled_at, s.status, s.description, s.reminder_sent, s.created_at + FROM schedules s + JOIN schedule_messages m ON m.schedule_id = s.id + WHERE m.message_id = $1 + LIMIT 1 + ` + row := r.db.QueryRowContext(ctx, q, messageID) + var sch Schedule + if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &sch, nil +} + +func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error { + const q = ` + UPDATE schedules + SET status = $1 + WHERE id = $2 + ` + _, err := r.db.ExecContext(ctx, q, status, scheduleID) + return err +} + +func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*Schedule, error) { + const q = ` + SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at + FROM schedules + WHERE guild_id = $1 + AND (requester_id = $2 OR invitee_id = $2) + AND status IN ('pending', 'accepted', 'reschedule_requested') + ORDER BY scheduled_at ASC + LIMIT $3 + ` + rows, err := r.db.QueryContext(ctx, q, guildID, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*Schedule + for rows.Next() { + var sch Schedule + if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { + return nil, err + } + out = append(out, &sch) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) { + const q = ` + SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at + FROM schedules + WHERE id = $1 + ` + row := r.db.QueryRowContext(ctx, q, id) + var sch Schedule + if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &sch, nil +} + +func (r *Repo) Reschedule(ctx context.Context, id int64, when time.Time) error { + const q = ` + UPDATE schedules + SET scheduled_at = $1, + status = 'pending', + reminder_sent = FALSE + WHERE id = $2 + ` + _, err := r.db.ExecContext(ctx, q, when, id) + return err +} + +// ListDueForReminder returns sessions starting between now and now+ahead, not yet reminded. +func (r *Repo) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*Schedule, error) { + const q = ` + SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at + FROM schedules + WHERE reminder_sent = FALSE + AND status IN ('pending', 'accepted', 'reschedule_requested') + AND scheduled_at > $1 + AND scheduled_at <= $2 + ` + rows, err := r.db.QueryContext(ctx, q, now, now.Add(ahead)) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*Schedule + for rows.Next() { + var sch Schedule + if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { + return nil, err + } + out = append(out, &sch) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error { + const q = ` + UPDATE schedules + SET reminder_sent = TRUE + WHERE id = $1 + ` + _, err := r.db.ExecContext(ctx, q, scheduleID) + return err +} + + 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/schedule/service.go b/internal/db/services/schedule/service.go new file mode 100644 index 0000000..6e74277 --- /dev/null +++ b/internal/db/services/schedule/service.go @@ -0,0 +1,53 @@ +package schedule + +import ( + "context" + "time" + + "velox-bot/internal/db/repos/schedulerepo" +) + +type Service struct { + repo *schedulerepo.Repo +} + +func New(repo *schedulerepo.Repo) *Service { + return &Service{repo: repo} +} + +func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) { + return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description) +} + +func (s *Service) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error { + return s.repo.AddMessage(ctx, scheduleID, messageID, channelID, isDM) +} + +func (s *Service) GetByMessageID(ctx context.Context, messageID int64) (*schedulerepo.Schedule, error) { + return s.repo.GetByMessageID(ctx, messageID) +} + +func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error { + return s.repo.UpdateStatus(ctx, scheduleID, status) +} + +func (s *Service) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*schedulerepo.Schedule, error) { + return s.repo.ListForUser(ctx, guildID, userID, limit) +} + +func (s *Service) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*schedulerepo.Schedule, error) { + return s.repo.ListDueForReminder(ctx, now, ahead) +} + +func (s *Service) MarkReminded(ctx context.Context, scheduleID int64) error { + return s.repo.MarkReminded(ctx, scheduleID) +} + +func (s *Service) GetByID(ctx context.Context, id int64) (*schedulerepo.Schedule, error) { + return s.repo.GetByID(ctx, id) +} + +func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) error { + return s.repo.Reschedule(ctx, id, when) +} + diff --git a/internal/db/services/services.go b/internal/db/services/services.go index a438dda..62ce89d 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -4,6 +4,8 @@ import ( "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" ) @@ -12,17 +14,21 @@ type Services struct { Level *level.Service 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, 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 new file mode 100644 index 0000000..f0aca0e --- /dev/null +++ b/internal/events/schedule_reactions.go @@ -0,0 +1,95 @@ +package events + +import ( + "context" + "log" + "strconv" + + "velox-bot/internal/db/repos/schedulerepo" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// HandleMessageReactionAdd reacts to emoji on schedule DM messages: +// ✅ accept, ❌ decline, 🔁 request reschedule. +func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd, svc *services.Services) { + if svc == nil || svc.Schedule == nil { + return + } + if r == nil || r.UserID == "" || r.MessageID == "" { + return + } + + // Ignore bot reactions. + if r.Member != nil && r.Member.User != nil && r.Member.User.Bot { + return + } + + msgID64, err := strconv.ParseInt(r.MessageID, 10, 64) + if err != nil || msgID64 == 0 { + return + } + + ctx := context.Background() + sch, err := svc.Schedule.GetByMessageID(ctx, msgID64) + if err != nil || sch == nil { + return + } + + // Only invitee can react to change status. + if r.UserID != strconv.FormatInt(sch.InviteeID, 10) { + return + } + + // Only pending can be transitioned by reaction. + if sch.Status != schedulerepo.StatusPending { + return + } + + var newStatus schedulerepo.ScheduleStatus + switch r.Emoji.Name { + case "✅": + newStatus = schedulerepo.StatusAccepted + case "❌": + newStatus = schedulerepo.StatusDeclined + case "🔁": + newStatus = schedulerepo.StatusRescheduleRequested + default: + return + } + + if err := svc.Schedule.UpdateStatus(ctx, sch.ID, newStatus); err != nil { + log.Printf("schedule: failed to update status id=%d: %v", sch.ID, err) + return + } + + statusText := map[schedulerepo.ScheduleStatus]string{ + schedulerepo.StatusAccepted: "accepted ✅", + schedulerepo.StatusDeclined: "declined ❌", + schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁", + }[newStatus] + + utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") + + // 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 new file mode 100644 index 0000000..dac4751 --- /dev/null +++ b/internal/events/schedule_reminders.go @@ -0,0 +1,107 @@ +package events + +import ( + "context" + "fmt" + "log" + "strconv" + "time" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// StartScheduleReminderLoop runs a background loop that periodically checks for +// upcoming sessions and sends DM reminders to all participants. +func StartScheduleReminderLoop(s *discordgo.Session, svc *services.Services) { + if svc == nil || svc.Schedule == nil || s == nil { + return + } + + const ( + interval = time.Minute // check every minute + remindBefore = 10 * time.Minute // remind 10 minutes before + initialDelay = 10 * time.Second + ) + + go func() { + // small delay so the bot can fully start + time.Sleep(initialDelay) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + now := time.Now().UTC() + ctx := context.Background() + + // Find all sessions that start in the next `remindBefore` window and haven't been reminded. + schedules, err := svc.Schedule.ListDueForReminder(ctx, now, remindBefore) + if err != nil { + log.Printf("schedule: reminder query failed: %v", err) + continue + } + for _, sch := range schedules { + if sch == nil { + continue + } + + utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") + desc := sch.Description + + // 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 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 { + log.Printf("schedule: failed to mark reminder sent for id=%d: %v", sch.ID, err) + } + } + } + }() +} + +func dmUser(s *discordgo.Session, userID int64, content string) error { + if s == nil || userID == 0 { + return nil + } + uid := strconv.FormatInt(userID, 10) + ch, err := s.UserChannelCreate(uid) + if err != nil { + return err + } + _, err = s.ChannelMessageSend(ch.ID, content) + return err +} + diff --git a/main.go b/main.go index fe7ea0f..fcb9467 100644 --- a/main.go +++ b/main.go @@ -13,11 +13,15 @@ import ( "velox-bot/internal/db/repos/levelrepo" "velox-bot/internal/db/repos/projectsrepo" "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" ) @@ -40,12 +44,16 @@ func main() { settingsRepo := settingsrepo.NewRepo(db) 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, 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 716f2dc..89b5597 100644 --- a/schema.sql +++ b/schema.sql @@ -100,4 +100,30 @@ CREATE TABLE IF NOT EXISTS project_members ( PRIMARY KEY (project_id, user_id) ); --- \ No newline at end of file +-- +-- Scheduling (one-to-one sessions) +-- +CREATE TABLE IF NOT EXISTS schedules ( + id BIGSERIAL PRIMARY KEY, + guild_id BIGINT NOT NULL, + requester_id BIGINT NOT NULL, + invitee_id BIGINT NOT NULL, + scheduled_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested + description TEXT, + reminder_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS schedule_messages ( + schedule_id BIGINT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE, + message_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + 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 +);