ft/schedule #4
+13
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+27
-1
@@ -100,4 +100,30 @@ CREATE TABLE IF NOT EXISTS project_members (
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
--
|
||||
--
|
||||
-- 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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user