feat: schedule rework
This commit is contained in:
+2
-6
@@ -1,5 +1,3 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
@@ -33,8 +31,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- lavalink
|
|
||||||
|
|
||||||
networks:
|
volumes:
|
||||||
lavalink:
|
velox_pgdata:
|
||||||
external: true
|
|
||||||
|
|||||||
+1
-8
@@ -32,16 +32,13 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
|||||||
// - MessageCreate (leveling): GuildMessages
|
// - MessageCreate (leveling): GuildMessages
|
||||||
// - GuildMemberAdd (welcome messages): GuildMembers
|
// - GuildMemberAdd (welcome messages): GuildMembers
|
||||||
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
||||||
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
|
|
||||||
session.Identify.Intents = discordgo.IntentsGuilds |
|
session.Identify.Intents = discordgo.IntentsGuilds |
|
||||||
discordgo.IntentsGuildBans |
|
discordgo.IntentsGuildBans |
|
||||||
discordgo.IntentsGuildMembers |
|
discordgo.IntentsGuildMembers |
|
||||||
discordgo.IntentsGuildMessages |
|
discordgo.IntentsGuildMessages |
|
||||||
discordgo.IntentsMessageContent |
|
discordgo.IntentsMessageContent |
|
||||||
discordgo.IntentsGuildVoiceStates |
|
discordgo.IntentsGuildVoiceStates |
|
||||||
discordgo.IntentsGuildMessageReactions |
|
discordgo.IntentsDirectMessages
|
||||||
discordgo.IntentsDirectMessages |
|
|
||||||
discordgo.IntentsDirectMessageReactions
|
|
||||||
|
|
||||||
return &Bot{
|
return &Bot{
|
||||||
Session: session,
|
Session: session,
|
||||||
@@ -122,10 +119,6 @@ func (b *Bot) Start() error {
|
|||||||
music.OnVoiceServerUpdate(ev)
|
music.OnVoiceServerUpdate(ev)
|
||||||
})
|
})
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) {
|
|
||||||
events.HandleMessageReactionAdd(s, r, b.Services)
|
|
||||||
})
|
|
||||||
|
|
||||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||||
events.StartTwitchLiveLoop(b.Session, b.Services)
|
events.StartTwitchLiveLoop(b.Session, b.Services)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"velox-bot/internal/commands/config"
|
"velox-bot/internal/commands/config"
|
||||||
"velox-bot/internal/commands/fun"
|
"velox-bot/internal/commands/fun"
|
||||||
"velox-bot/internal/commands/help"
|
"velox-bot/internal/commands/help"
|
||||||
@@ -72,8 +73,18 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
}
|
}
|
||||||
case discordgo.InteractionMessageComponent:
|
case discordgo.InteractionMessageComponent:
|
||||||
data := i.MessageComponentData()
|
data := i.MessageComponentData()
|
||||||
if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" {
|
switch {
|
||||||
|
case strings.HasPrefix(data.CustomID, "music:"):
|
||||||
music.HandleComponent(s, i)
|
music.HandleComponent(s, i)
|
||||||
|
case strings.HasPrefix(data.CustomID, "schedule:"):
|
||||||
|
schedule.HandleComponent(s, i)
|
||||||
|
}
|
||||||
|
case discordgo.InteractionModalSubmit:
|
||||||
|
data := i.ModalSubmitData()
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(data.CustomID, "schedule:"):
|
||||||
|
schedule.HandleModalSubmit(s, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,4 +74,3 @@ func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,794 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"velox-bot/internal/commands/schedule/shared"
|
||||||
|
"velox-bot/internal/db/repos/schedulerepo"
|
||||||
|
"velox-bot/internal/db/services"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scheduleActionButtons(scheduleID int64) []discordgo.MessageComponent {
|
||||||
|
|
||||||
|
idStr := strconv.FormatInt(scheduleID, 10)
|
||||||
|
|
||||||
|
return []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.Button{
|
||||||
|
Label: "Accept",
|
||||||
|
Style: discordgo.SuccessButton,
|
||||||
|
CustomID: "schedule:accept:" + idStr,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Label: "Decline",
|
||||||
|
Style: discordgo.DangerButton,
|
||||||
|
CustomID: "schedule:decline:" + idStr,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Label: "Reschedule",
|
||||||
|
Style: discordgo.SecondaryButton,
|
||||||
|
CustomID: "schedule:reschedule:" + idStr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rescheduleProposalButtons(scheduleID int64) []discordgo.MessageComponent {
|
||||||
|
idStr := strconv.FormatInt(scheduleID, 10)
|
||||||
|
|
||||||
|
return []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.Button{
|
||||||
|
Label: "Accept",
|
||||||
|
Style: discordgo.SuccessButton,
|
||||||
|
CustomID: "schedule:accept_reschedule:" + idStr,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Label: "Decline",
|
||||||
|
Style: discordgo.DangerButton,
|
||||||
|
CustomID: "schedule:decline_reschedule:" + idStr,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Label: "Propose different time",
|
||||||
|
Style: discordgo.SecondaryButton,
|
||||||
|
CustomID: "schedule:reschedule:" + idStr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUserSelectCustomID = "schedule:create_user_select"
|
||||||
|
|
||||||
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if i.MessageComponentData().CustomID == createUserSelectCustomID {
|
||||||
|
handleCreateUserSelect(s, i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
action, scheduleID := parseComponentCustomID(i.MessageComponentData().CustomID)
|
||||||
|
|
||||||
|
userIDStr := interactionUserID(i)
|
||||||
|
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error parsing user ID.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
|
||||||
|
if err != nil || sch == nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error retrieving schedule.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var status schedulerepo.ScheduleStatus
|
||||||
|
var message string
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "accept":
|
||||||
|
if userID != sch.InviteeID {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You are not authorized to perform this action.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sch.Status != schedulerepo.StatusPending {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "This schedule is no longer pending.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status = schedulerepo.StatusAccepted
|
||||||
|
message = "✅ Accepted"
|
||||||
|
case "decline":
|
||||||
|
if userID != sch.InviteeID {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You are not authorized to perform this action.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sch.Status != schedulerepo.StatusPending {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "This schedule is no longer pending.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status = schedulerepo.StatusDeclined
|
||||||
|
message = "❌ Declined"
|
||||||
|
case "reschedule":
|
||||||
|
if userID != sch.InviteeID && userID != sch.RequesterID {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You are not authorized to perform this action.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "This schedule can't be rescheduled right now.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseModal,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
CustomID: "schedule:reschedule_modal:" + strconv.FormatInt(scheduleID, 10),
|
||||||
|
Title: "Propose a new time",
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.TextInput{
|
||||||
|
CustomID: "date",
|
||||||
|
Label: "Date (YYYY-MM-DD)",
|
||||||
|
Style: discordgo.TextInputShort,
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "2026-01-02",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.TextInput{
|
||||||
|
CustomID: "time",
|
||||||
|
Label: "Time (HH:MM, your timezone)",
|
||||||
|
Style: discordgo.TextInputShort,
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "15:04",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case "accept_reschedule":
|
||||||
|
if sch.ProposedBy == nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "There is no reschedule proposal to accept.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You are not authorized to perform this action.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sch.Status != schedulerepo.StatusRescheduleRequested {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "There is no reschedule request to accept.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = services.Global.Schedule.AcceptReschedule(ctx, scheduleID)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error accepting reschedule.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseUpdateMessage,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "✅ Reschedule accepted",
|
||||||
|
Components: []discordgo.MessageComponent{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
newTime := *sch.ProposedAt
|
||||||
|
utcStr := newTime.UTC().Format("2006-01-02 15:04")
|
||||||
|
|
||||||
|
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
zone = "UTC"
|
||||||
|
}
|
||||||
|
localStr := newTime.In(loc).Format("2006-01-02 15:04")
|
||||||
|
|
||||||
|
content := "Your proposed time for the session was accepted: " +
|
||||||
|
utcStr + " UTC (" + localStr + " " + zone + ")."
|
||||||
|
|
||||||
|
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
|
||||||
|
if err == nil {
|
||||||
|
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
||||||
|
log.Printf("schedule: failed to DM proposer about accepted reschedule: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "decline_reschedule":
|
||||||
|
if sch.ProposedBy == nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "There is no reschedule proposal to accept.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You are not authorized to perform this action.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sch.Status != schedulerepo.StatusRescheduleRequested {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "There is no reschedule request to accept.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = services.Global.Schedule.DeclineReschedule(ctx, scheduleID)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error declining reschedule.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseUpdateMessage,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "❌ Reschedule declined",
|
||||||
|
Components: []discordgo.MessageComponent{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||||
|
|
||||||
|
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
zone = "UTC"
|
||||||
|
}
|
||||||
|
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
|
||||||
|
|
||||||
|
content := "Your proposed new time was declined. The session remains scheduled for " +
|
||||||
|
utcStr + " UTC (" + localStr + " " + zone + ")."
|
||||||
|
|
||||||
|
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
|
||||||
|
if err == nil {
|
||||||
|
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
||||||
|
log.Printf("schedule: failed to DM proposer about declined reschedule: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Unknown action.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = services.Global.Schedule.UpdateStatus(ctx, scheduleID, status)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error updating schedule.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseUpdateMessage,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: message,
|
||||||
|
Components: []discordgo.MessageComponent{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
statusText := map[schedulerepo.ScheduleStatus]string{
|
||||||
|
schedulerepo.StatusAccepted: "accepted ✅",
|
||||||
|
schedulerepo.StatusDeclined: "declined ❌",
|
||||||
|
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
|
||||||
|
}[status]
|
||||||
|
|
||||||
|
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||||
|
|
||||||
|
// Notify requester with local time if configured.
|
||||||
|
if services.Global.UserSettings != nil {
|
||||||
|
loc, zone, _, err := services.Global.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateUserSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
values := i.MessageComponentData().Values
|
||||||
|
if len(values) == 0 {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "No user selected.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetUserID := values[0]
|
||||||
|
|
||||||
|
if targetUserID == interactionUserID(i) {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You cannot schedule a session with yourself.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseModal,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
CustomID: "schedule:create_modal:" + targetUserID,
|
||||||
|
Title: "Schedule a session",
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.TextInput{
|
||||||
|
CustomID: "date",
|
||||||
|
Label: "Date (YYYY-MM-DD)",
|
||||||
|
Style: discordgo.TextInputShort,
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "2026-01-02",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.TextInput{
|
||||||
|
CustomID: "time",
|
||||||
|
Label: "Time (HH:MM, your timezone)",
|
||||||
|
Style: discordgo.TextInputShort,
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "15:04",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.TextInput{
|
||||||
|
CustomID: "description",
|
||||||
|
Label: "Description (optional)",
|
||||||
|
Style: discordgo.TextInputShort,
|
||||||
|
Required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func modalTextInputValues(components []discordgo.MessageComponent) map[string]string {
|
||||||
|
values := make(map[string]string)
|
||||||
|
for _, c := range components {
|
||||||
|
row, ok := c.(*discordgo.ActionsRow)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, rc := range row.Components {
|
||||||
|
ti, ok := rc.(*discordgo.TextInput)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values[ti.CustomID] = ti.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateModalSubmit(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, inviteeID int64) {
|
||||||
|
guildID, ok := shared.ParseGuildID(s, i)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requesterID, err := strconv.ParseInt(interactionUserID(i), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Invalid user ID.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if requesterID == inviteeID {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You cannot schedule a session with yourself.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
values := modalTextInputValues(i.ModalSubmitData().Components)
|
||||||
|
dateStr := strings.TrimSpace(values["date"])
|
||||||
|
timeStr := strings.TrimSpace(values["time"])
|
||||||
|
desc := strings.TrimSpace(values["description"])
|
||||||
|
|
||||||
|
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, requesterID)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
zone = "UTC"
|
||||||
|
}
|
||||||
|
|
||||||
|
when, err := shared.ParseScheduleDatetime(dateStr+" "+timeStr, loc)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time, in your timezone (" + zone + ").",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when = when.In(time.UTC)
|
||||||
|
|
||||||
|
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Failed to create schedule.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dmCh, err := s.UserChannelCreate(strconv.FormatInt(inviteeID, 10))
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Session created, but failed to DM the invited user (are DMs disabled?).",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "New session request",
|
||||||
|
Description: fmt.Sprintf("You have been invited to a session by <@%d>.", requesterID),
|
||||||
|
Color: 0x4caf50,
|
||||||
|
}
|
||||||
|
utcStr := when.Format("2006-01-02 15:04")
|
||||||
|
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(ctx, 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||||
|
Content: "Respond to this session request:",
|
||||||
|
Embed: embed,
|
||||||
|
Components: scheduleActionButtons(scheduleID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Session created, but failed to DM the invited user.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Session request created and sent to <@" + strconv.FormatInt(inviteeID, 10) + ">.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
ctx := context.Background()
|
||||||
|
action, idOrUserID := parseComponentCustomID(i.ModalSubmitData().CustomID)
|
||||||
|
|
||||||
|
if action == "create_modal" {
|
||||||
|
handleCreateModalSubmit(ctx, s, i, idOrUserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleID := idOrUserID
|
||||||
|
if scheduleID == 0 {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Invalid schedule ID.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modalValues := modalTextInputValues(i.ModalSubmitData().Components)
|
||||||
|
datetimeInput := strings.TrimSpace(modalValues["date"]) + " " + strings.TrimSpace(modalValues["time"])
|
||||||
|
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
|
||||||
|
if err != nil || sch == nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error retrieving schedule.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDStr := interactionUserID(i)
|
||||||
|
userID, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error parsing user ID.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID != sch.InviteeID && userID != sch.RequesterID {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You are not authorized to perform this action.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, _, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
loc = sch.ScheduledAt.UTC().Location()
|
||||||
|
}
|
||||||
|
|
||||||
|
newTime, err := shared.ParseScheduleDatetime(datetimeInput, loc)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
//Convert time to UTC before storing in the database
|
||||||
|
newTime = newTime.UTC()
|
||||||
|
|
||||||
|
err = services.Global.Schedule.ProposeReschedule(ctx, scheduleID, newTime, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error proposing reschedule.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
otherID := sch.RequesterID
|
||||||
|
if userID == sch.RequesterID {
|
||||||
|
otherID = sch.InviteeID
|
||||||
|
}
|
||||||
|
|
||||||
|
dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Reschedule Proposal",
|
||||||
|
Description: fmt.Sprintf("<@%d> has proposed a new time for your session request. Please respond.", userID),
|
||||||
|
Color: 0x4caf50,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show both UTC and invitee-local time if available.
|
||||||
|
utcStr := newTime.Format("2006-01-02 15:04")
|
||||||
|
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), otherID)
|
||||||
|
if err != nil {
|
||||||
|
locInv = time.UTC
|
||||||
|
zoneInv = "UTC"
|
||||||
|
}
|
||||||
|
localInvStr := newTime.In(locInv).Format("2006-01-02 15:04")
|
||||||
|
embed.Fields = []*discordgo.MessageEmbedField{
|
||||||
|
{
|
||||||
|
Name: "When (UTC)",
|
||||||
|
Value: utcStr,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "When (" + zoneInv + ")",
|
||||||
|
Value: localInvStr,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||||
|
Content: "A new time has been proposed for your session:",
|
||||||
|
Embed: embed,
|
||||||
|
Components: rescheduleProposalButtons(scheduleID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to DM the other participant.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseComponentCustomID(customID string) (string, int64) {
|
||||||
|
parts := strings.Split(customID, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
action := parts[1]
|
||||||
|
scheduleID, err := strconv.ParseInt(parts[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return action, scheduleID
|
||||||
|
}
|
||||||
|
|
||||||
|
func interactionUserID(i *discordgo.InteractionCreate) string {
|
||||||
|
if i.Member != nil && i.Member.User != nil {
|
||||||
|
return i.Member.User.ID
|
||||||
|
}
|
||||||
|
if i.User != nil {
|
||||||
|
return i.User.ID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"velox-bot/internal/commands/schedule/shared"
|
"velox-bot/internal/commands/schedule/shared"
|
||||||
|
"velox-bot/internal/db/repos/schedulerepo"
|
||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
@@ -21,26 +22,6 @@ var Schedule = &discordgo.ApplicationCommand{
|
|||||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a new session with a user",
|
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,
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
@@ -93,8 +74,7 @@ func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
guildID, ok := shared.ParseGuildID(s, i)
|
if _, ok := shared.ParseGuildID(s, i); !ok {
|
||||||
if !ok {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if i.Member == nil || i.Member.User == nil {
|
if i.Member == nil || i.Member.User == nil {
|
||||||
@@ -102,133 +82,24 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := i.ApplicationCommandData()
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
if len(data.Options) == 0 {
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
shared.RespondEphemeral(s, i, "Missing options.")
|
Data: &discordgo.InteractionResponseData{
|
||||||
return
|
Content: "Who do you want to schedule a session with?",
|
||||||
}
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
opt := data.Options[0]
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{
|
||||||
var (
|
Components: []discordgo.MessageComponent{
|
||||||
targetUser *discordgo.User
|
discordgo.SelectMenu{
|
||||||
whenStr string
|
MenuType: discordgo.UserSelectMenu,
|
||||||
desc string
|
CustomID: createUserSelectCustomID,
|
||||||
)
|
Placeholder: "Select a user",
|
||||||
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) {
|
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
@@ -338,6 +209,11 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted {
|
||||||
|
shared.RespondEphemeral(s, i, "This session can't be rescheduled right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Use rescheduler's timezone for parsing.
|
// Use rescheduler's timezone for parsing.
|
||||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
|
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -345,19 +221,18 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
zone = "UTC"
|
zone = "UTC"
|
||||||
}
|
}
|
||||||
// Expect explicit layout "2006-01-02 15:04" in user's timezone.
|
// Expect explicit layout "2006-01-02 15:04" in user's timezone.
|
||||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
|
when, err := shared.ParseScheduleDatetime(whenStr, loc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
|
shared.RespondEphemeral(s, i, err.Error()+" (use `2006-01-02 15:04` in your timezone: "+zone+")")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when = when.In(time.UTC)
|
when = when.In(time.UTC)
|
||||||
|
|
||||||
if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil {
|
if err := services.Global.Schedule.ProposeReschedule(ctx, sch.ID, when, userID); err != nil {
|
||||||
shared.RespondEphemeral(s, i, "Failed to reschedule session.")
|
shared.RespondEphemeral(s, i, "Failed to propose reschedule.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify participants via DM and send new request DM to invitee.
|
|
||||||
whenFmt := when.Format("2006-01-02 15:04")
|
whenFmt := when.Format("2006-01-02 15:04")
|
||||||
|
|
||||||
otherID := sch.InviteeID
|
otherID := sch.InviteeID
|
||||||
@@ -365,75 +240,50 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
otherID = sch.RequesterID
|
otherID = sch.RequesterID
|
||||||
}
|
}
|
||||||
|
|
||||||
// DM both about the change, including their local times if available.
|
dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
|
||||||
for _, uid := range []int64{sch.RequesterID, sch.InviteeID} {
|
if err != nil {
|
||||||
locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid)
|
shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant (are DMs disabled?).")
|
||||||
if err != nil {
|
return
|
||||||
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.
|
embed := &discordgo.MessageEmbed{
|
||||||
inviteeStr := strconv.FormatInt(sch.InviteeID, 10)
|
Title: "Reschedule proposed",
|
||||||
inviteeUser, err := s.User(inviteeStr)
|
Description: fmt.Sprintf("<@%s> has proposed a new time for your session.", i.Member.User.ID),
|
||||||
if err == nil && inviteeUser != nil {
|
Color: 0x4caf50,
|
||||||
dmCh, err := s.UserChannelCreate(inviteeStr)
|
}
|
||||||
if err == nil {
|
// Show both UTC and the other participant's local time if available.
|
||||||
embed := &discordgo.MessageEmbed{
|
locOther, zoneOther, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, otherID)
|
||||||
Title: "Rescheduled session",
|
if errTZ != nil {
|
||||||
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID),
|
locOther = time.UTC
|
||||||
Color: 0xffc107,
|
zoneOther = "UTC"
|
||||||
}
|
}
|
||||||
// Show both UTC and invitee-local time if available.
|
localOtherStr := when.In(locOther).Format("2006-01-02 15:04")
|
||||||
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
embed.Fields = []*discordgo.MessageEmbedField{
|
||||||
if errTZ != nil {
|
{
|
||||||
locInv = time.UTC
|
Name: "When (UTC)",
|
||||||
zoneInv = "UTC"
|
Value: whenFmt,
|
||||||
}
|
},
|
||||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
{
|
||||||
embed.Fields = []*discordgo.MessageEmbedField{
|
Name: "When (" + zoneOther + ")",
|
||||||
{
|
Value: localOtherStr,
|
||||||
Name: "When (UTC)",
|
},
|
||||||
Value: whenFmt,
|
}
|
||||||
},
|
if sch.Description != "" {
|
||||||
{
|
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||||
Name: "When (" + zoneInv + ")",
|
Name: "Description",
|
||||||
Value: localInvStr,
|
Value: sch.Description,
|
||||||
},
|
})
|
||||||
}
|
|
||||||
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.")
|
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||||
|
Content: "A new time has been proposed for your session:",
|
||||||
|
Embed: embed,
|
||||||
|
Components: rescheduleProposalButtons(sch.ID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,13 @@ var (
|
|||||||
Schedule *discordgo.ApplicationCommand = public.Schedule
|
Schedule *discordgo.ApplicationCommand = public.Schedule
|
||||||
)
|
)
|
||||||
|
|
||||||
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) }
|
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.ScheduleHandler(s, i)
|
||||||
|
}
|
||||||
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.HandleComponent(s, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.HandleModalSubmit(s, i)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
package shared
|
package shared
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var scheduleDatetimeLayouts = []string{
|
||||||
|
"2006-01-02 15:04",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"01/02/2006 15:04",
|
||||||
|
}
|
||||||
|
|
||||||
func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
@@ -18,6 +27,15 @@ func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseScheduleDatetime(input string, loc *time.Location) (time.Time, error) {
|
||||||
|
for _, layout := range scheduleDatetimeLayouts {
|
||||||
|
if t, err := time.ParseInLocation(layout, input, loc); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("invalid schedule datetime format")
|
||||||
|
}
|
||||||
|
|
||||||
func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||||
if i.GuildID == "" {
|
if i.GuildID == "" {
|
||||||
RespondEphemeral(s, i, "This command can only be used in a server.")
|
RespondEphemeral(s, i, "This command can only be used in a server.")
|
||||||
@@ -42,4 +60,3 @@ func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64,
|
|||||||
}
|
}
|
||||||
return guildID, true
|
return guildID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,15 +20,18 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Schedule struct {
|
type Schedule struct {
|
||||||
ID int64
|
ID int64
|
||||||
GuildID int64
|
GuildID int64
|
||||||
RequesterID int64
|
RequesterID int64
|
||||||
InviteeID int64
|
InviteeID int64
|
||||||
ScheduledAt time.Time
|
ScheduledAt time.Time
|
||||||
Status ScheduleStatus
|
ProposedAt *time.Time
|
||||||
Description string
|
ProposedBy *int64
|
||||||
ReminderSent bool
|
PreRescheduleStatus *ScheduleStatus
|
||||||
CreatedAt time.Time
|
Status ScheduleStatus
|
||||||
|
Description string
|
||||||
|
ReminderSent bool
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepo(db *sql.DB) *Repo {
|
func NewRepo(db *sql.DB) *Repo {
|
||||||
@@ -48,35 +51,6 @@ func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, invitee
|
|||||||
return id, nil
|
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 {
|
func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error {
|
||||||
const q = `
|
const q = `
|
||||||
UPDATE schedules
|
UPDATE schedules
|
||||||
@@ -119,13 +93,13 @@ func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int
|
|||||||
|
|
||||||
func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) {
|
func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, proposed_at, proposed_by, pre_reschedule_status, status, description, reminder_sent, created_at
|
||||||
FROM schedules
|
FROM schedules
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
row := r.db.QueryRowContext(ctx, q, id)
|
row := r.db.QueryRowContext(ctx, q, id)
|
||||||
var sch Schedule
|
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 := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.ProposedAt, &sch.ProposedBy, &sch.PreRescheduleStatus, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -186,4 +160,43 @@ func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error {
|
||||||
|
const q = `
|
||||||
|
UPDATE schedules
|
||||||
|
SET proposed_at = $1,
|
||||||
|
proposed_by = $2,
|
||||||
|
pre_reschedule_status = status,
|
||||||
|
status = 'reschedule_requested'
|
||||||
|
WHERE id = $3
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, newTime, proposedBy, scheduleID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) AcceptReschedule(ctx context.Context, scheduleID int64) error {
|
||||||
|
const q = `
|
||||||
|
UPDATE schedules
|
||||||
|
SET scheduled_at = proposed_at,
|
||||||
|
proposed_at = NULL,
|
||||||
|
proposed_by = NULL,
|
||||||
|
pre_reschedule_status = NULL,
|
||||||
|
status = 'accepted',
|
||||||
|
reminder_sent = FALSE
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, scheduleID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) DeclineReschedule(ctx context.Context, scheduleID int64) error {
|
||||||
|
const q = `
|
||||||
|
UPDATE schedules
|
||||||
|
SET proposed_at = NULL,
|
||||||
|
proposed_by = NULL,
|
||||||
|
status = pre_reschedule_status,
|
||||||
|
pre_reschedule_status = NULL
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, scheduleID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,14 +19,6 @@ func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, invi
|
|||||||
return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description)
|
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 {
|
func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error {
|
||||||
return s.repo.UpdateStatus(ctx, scheduleID, status)
|
return s.repo.UpdateStatus(ctx, scheduleID, status)
|
||||||
}
|
}
|
||||||
@@ -51,3 +43,14 @@ func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) erro
|
|||||||
return s.repo.Reschedule(ctx, id, when)
|
return s.repo.Reschedule(ctx, id, when)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error {
|
||||||
|
return s.repo.ProposeReschedule(ctx, scheduleID, newTime, proposedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AcceptReschedule(ctx context.Context, id int64) error {
|
||||||
|
return s.repo.AcceptReschedule(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeclineReschedule(ctx context.Context, id int64) error {
|
||||||
|
return s.repo.DeclineReschedule(ctx, id)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+20
-11
@@ -3,6 +3,7 @@ package music
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -67,18 +68,26 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string)
|
|||||||
password = "youshallnotpass"
|
password = "youshallnotpass"
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{
|
// Connecting to the Lavalink node is a blocking network call (and, on
|
||||||
Name: "main",
|
// failure, disgolink retries indefinitely with backoff) — do it in the
|
||||||
Address: u.Host,
|
// background so bot startup isn't held hostage by it. Music commands
|
||||||
Password: password,
|
// simply fail with "not initialized" via the manager==nil checks until
|
||||||
Secure: secure,
|
// this completes.
|
||||||
})
|
go func() {
|
||||||
if err != nil {
|
_, err := m.client.AddNode(context.Background(), disgolink.NodeConfig{
|
||||||
return fmt.Errorf("add lavalink node: %w", err)
|
Name: "main",
|
||||||
}
|
Address: u.Host,
|
||||||
|
Password: password,
|
||||||
|
Secure: secure,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("music: failed to connect to lavalink node: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager = m
|
||||||
|
go idleDisconnectLoop()
|
||||||
|
}()
|
||||||
|
|
||||||
manager = m
|
|
||||||
go idleDisconnectLoop()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -110,7 +110,14 @@ CREATE TABLE IF NOT EXISTS schedules (
|
|||||||
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
|
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
|
||||||
description TEXT,
|
description TEXT,
|
||||||
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
-- Reschedule negotiation: a proposed new time awaiting the counterpart's
|
||||||
|
-- response, kept separate from scheduled_at until accepted.
|
||||||
|
proposed_at TIMESTAMPTZ,
|
||||||
|
proposed_by BIGINT,
|
||||||
|
-- Snapshot of status before entering reschedule_requested, so a decline
|
||||||
|
-- knows whether to revert to 'pending' or 'accepted'.
|
||||||
|
pre_reschedule_status TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS schedule_messages (
|
CREATE TABLE IF NOT EXISTS schedule_messages (
|
||||||
|
|||||||
Reference in New Issue
Block a user