feat: timezone awarenwss
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"velox-bot/internal/commands/help"
|
"velox-bot/internal/commands/help"
|
||||||
cmdlevel "velox-bot/internal/commands/level"
|
cmdlevel "velox-bot/internal/commands/level"
|
||||||
"velox-bot/internal/commands/meeting"
|
"velox-bot/internal/commands/meeting"
|
||||||
|
"velox-bot/internal/commands/timezone"
|
||||||
"velox-bot/internal/commands/schedule"
|
"velox-bot/internal/commands/schedule"
|
||||||
"velox-bot/internal/commands/projects"
|
"velox-bot/internal/commands/projects"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
|||||||
cmdlevel.Leaderboard,
|
cmdlevel.Leaderboard,
|
||||||
cmdlevel.Rewards,
|
cmdlevel.Rewards,
|
||||||
meeting.Meeting,
|
meeting.Meeting,
|
||||||
|
timezone.Timezone,
|
||||||
schedule.Schedule,
|
schedule.Schedule,
|
||||||
projects.Projects,
|
projects.Projects,
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
|
|||||||
"leaderboard": cmdlevel.LeaderboardHandler,
|
"leaderboard": cmdlevel.LeaderboardHandler,
|
||||||
"rewards": cmdlevel.RewardsHandler,
|
"rewards": cmdlevel.RewardsHandler,
|
||||||
"meeting": meeting.MeetingHandler,
|
"meeting": meeting.MeetingHandler,
|
||||||
|
"timezone": timezone.TimezoneHandler,
|
||||||
"schedule": schedule.ScheduleHandler,
|
"schedule": schedule.ScheduleHandler,
|
||||||
"projects": projects.ProjectsHandler,
|
"projects": projects.ProjectsHandler,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,19 +134,6 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
whenStr = strings.TrimSpace(whenStr)
|
|
||||||
if whenStr == "" {
|
|
||||||
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect layout "2006-01-02 15:04" in UTC.
|
|
||||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, time.UTC)
|
|
||||||
if err != nil {
|
|
||||||
shared.RespondEphemeral(s, i, "Invalid datetime format. Use `2006-01-02 15:04` in UTC.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||||
@@ -158,6 +145,26 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
return
|
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()
|
ctx := context.Background()
|
||||||
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
|
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,11 +183,23 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
Title: "New session request",
|
Title: "New session request",
|
||||||
Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID),
|
Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID),
|
||||||
Color: 0x4caf50,
|
Color: 0x4caf50,
|
||||||
Fields: []*discordgo.MessageEmbedField{
|
}
|
||||||
{
|
// Show both UTC and invitee-local time if available.
|
||||||
Name: "When (UTC)",
|
utcStr := when.Format("2006-01-02 15:04")
|
||||||
Value: 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 != "" {
|
if desc != "" {
|
||||||
@@ -298,12 +317,6 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, time.UTC)
|
|
||||||
if err != nil {
|
|
||||||
shared.RespondEphemeral(s, i, "Invalid datetime format. Use `2006-01-02 15:04` in UTC.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||||
@@ -325,6 +338,20 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
return
|
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 {
|
if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil {
|
||||||
shared.RespondEphemeral(s, i, "Failed to reschedule session.")
|
shared.RespondEphemeral(s, i, "Failed to reschedule session.")
|
||||||
return
|
return
|
||||||
@@ -338,12 +365,18 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
otherID = sch.RequesterID
|
otherID = sch.RequesterID
|
||||||
}
|
}
|
||||||
|
|
||||||
// DM both about the change
|
// DM both about the change, including their local times if available.
|
||||||
msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC with <@%d>.", sch.ID, whenFmt, otherID)
|
|
||||||
if sch.Description != "" {
|
|
||||||
msg += "\nTopic: " + sch.Description
|
|
||||||
}
|
|
||||||
for _, uid := range []int64{sch.RequesterID, sch.InviteeID} {
|
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))
|
ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -361,11 +394,22 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
Title: "Rescheduled session",
|
Title: "Rescheduled session",
|
||||||
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID),
|
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID),
|
||||||
Color: 0xffc107,
|
Color: 0xffc107,
|
||||||
Fields: []*discordgo.MessageEmbedField{
|
}
|
||||||
{
|
// Show both UTC and invitee-local time if available.
|
||||||
Name: "When (UTC)",
|
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
||||||
Value: whenFmt,
|
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 != "" {
|
if sch.Description != "" {
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"velox-bot/internal/db/services/meeting"
|
||||||
"velox-bot/internal/db/services/schedule"
|
"velox-bot/internal/db/services/schedule"
|
||||||
|
"velox-bot/internal/db/services/usersettings"
|
||||||
"velox-bot/internal/db/services/projects"
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
)
|
)
|
||||||
@@ -14,18 +15,20 @@ type Services struct {
|
|||||||
LevelSettings *levelsettings.Service
|
LevelSettings *levelsettings.Service
|
||||||
Meeting *meeting.Service
|
Meeting *meeting.Service
|
||||||
Schedule *schedule.Service
|
Schedule *schedule.Service
|
||||||
|
UserSettings *usersettings.Service
|
||||||
Projects *projects.Service
|
Projects *projects.Service
|
||||||
RPS *rps.Service
|
RPS *rps.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
var Global *Services
|
var Global *Services
|
||||||
|
|
||||||
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, projects *projects.Service, rps *rps.Service) *Services {
|
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service) *Services {
|
||||||
s := &Services{
|
s := &Services{
|
||||||
Level: level,
|
Level: level,
|
||||||
LevelSettings: levelSettings,
|
LevelSettings: levelSettings,
|
||||||
Meeting: meeting,
|
Meeting: meeting,
|
||||||
Schedule: schedule,
|
Schedule: schedule,
|
||||||
|
UserSettings: userSettings,
|
||||||
Projects: projects,
|
Projects: projects,
|
||||||
RPS: rps,
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -64,24 +64,32 @@ func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReaction
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify requester via DM.
|
|
||||||
requesterID := strconv.FormatInt(sch.RequesterID, 10)
|
|
||||||
dmCh, err := s.UserChannelCreate(requesterID)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
statusText := map[schedulerepo.ScheduleStatus]string{
|
statusText := map[schedulerepo.ScheduleStatus]string{
|
||||||
schedulerepo.StatusAccepted: "accepted ✅",
|
schedulerepo.StatusAccepted: "accepted ✅",
|
||||||
schedulerepo.StatusDeclined: "declined ❌",
|
schedulerepo.StatusDeclined: "declined ❌",
|
||||||
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
|
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
|
||||||
}[newStatus]
|
}[newStatus]
|
||||||
|
|
||||||
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
|
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||||
sch.ScheduledAt.UTC().Format("2006-01-02 15:04") + " UTC has been " + statusText + "."
|
|
||||||
|
|
||||||
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
// Notify requester with local time if configured.
|
||||||
log.Printf("schedule: failed to DM requester about status change: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,26 +47,41 @@ func StartScheduleReminderLoop(s *discordgo.Session, svc *services.Services) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build reminder text
|
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||||
whenStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
|
||||||
base := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC with <@%d>.", whenStr, sch.InviteeID)
|
|
||||||
desc := sch.Description
|
desc := sch.Description
|
||||||
if desc != "" {
|
|
||||||
base += "\nTopic: " + desc
|
// Notify requester with local time if available.
|
||||||
|
if svc.UserSettings != nil {
|
||||||
|
locReq, zoneReq, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
|
||||||
|
if err != nil {
|
||||||
|
locReq = sch.ScheduledAt.UTC().Location()
|
||||||
|
zoneReq = "UTC"
|
||||||
|
}
|
||||||
|
localReq := sch.ScheduledAt.In(locReq).Format("2006-01-02 15:04")
|
||||||
|
base := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localReq, zoneReq, sch.InviteeID)
|
||||||
|
if desc != "" {
|
||||||
|
base += "\nTopic: " + desc
|
||||||
|
}
|
||||||
|
if err := dmUser(s, sch.RequesterID, base); err != nil {
|
||||||
|
log.Printf("schedule: failed to DM requester reminder: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify requester
|
// Notify invitee with their local time if available.
|
||||||
if err := dmUser(s, sch.RequesterID, base); err != nil {
|
if svc.UserSettings != nil {
|
||||||
log.Printf("schedule: failed to DM requester reminder: %v", err)
|
locInv, zoneInv, _, err := svc.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
||||||
}
|
if err != nil {
|
||||||
|
locInv = sch.ScheduledAt.UTC().Location()
|
||||||
// Notify invitee (mirror text with requester mention)
|
zoneInv = "UTC"
|
||||||
baseInvitee := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC with <@%d>.", whenStr, sch.RequesterID)
|
}
|
||||||
if desc != "" {
|
localInv := sch.ScheduledAt.In(locInv).Format("2006-01-02 15:04")
|
||||||
baseInvitee += "\nTopic: " + desc
|
baseInvitee := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localInv, zoneInv, sch.RequesterID)
|
||||||
}
|
if desc != "" {
|
||||||
if err := dmUser(s, sch.InviteeID, baseInvitee); err != nil {
|
baseInvitee += "\nTopic: " + desc
|
||||||
log.Printf("schedule: failed to DM invitee reminder: %v", err)
|
}
|
||||||
|
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 {
|
if err := svc.Schedule.MarkReminded(ctx, sch.ID); err != nil {
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import (
|
|||||||
"velox-bot/internal/db/repos/rpsrepo"
|
"velox-bot/internal/db/repos/rpsrepo"
|
||||||
"velox-bot/internal/db/repos/schedulerepo"
|
"velox-bot/internal/db/repos/schedulerepo"
|
||||||
"velox-bot/internal/db/repos/settingsrepo"
|
"velox-bot/internal/db/repos/settingsrepo"
|
||||||
|
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
"velox-bot/internal/db/services/level"
|
"velox-bot/internal/db/services/level"
|
||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"velox-bot/internal/db/services/meeting"
|
||||||
"velox-bot/internal/db/services/schedule"
|
"velox-bot/internal/db/services/schedule"
|
||||||
|
"velox-bot/internal/db/services/usersettings"
|
||||||
"velox-bot/internal/db/services/projects"
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
)
|
)
|
||||||
@@ -43,13 +45,15 @@ func main() {
|
|||||||
rpsRepo := rpsrepo.NewRepo(db)
|
rpsRepo := rpsrepo.NewRepo(db)
|
||||||
projectsRepo := projectsrepo.NewRepo(db)
|
projectsRepo := projectsrepo.NewRepo(db)
|
||||||
scheduleRepo := schedulerepo.NewRepo(db)
|
scheduleRepo := schedulerepo.NewRepo(db)
|
||||||
|
userSettingsRepo := usersettingsrepo.NewRepo(db)
|
||||||
levelService := level.New(levelRepo, settingsRepo)
|
levelService := level.New(levelRepo, settingsRepo)
|
||||||
levelSettingsService := levelsettings.New(settingsRepo)
|
levelSettingsService := levelsettings.New(settingsRepo)
|
||||||
meetingService := meeting.New(settingsRepo)
|
meetingService := meeting.New(settingsRepo)
|
||||||
scheduleService := schedule.New(scheduleRepo)
|
scheduleService := schedule.New(scheduleRepo)
|
||||||
|
userSettingsService := usersettings.New(userSettingsRepo)
|
||||||
projectsService := projects.New(projectsRepo)
|
projectsService := projects.New(projectsRepo)
|
||||||
rpsService := rps.New(rpsRepo)
|
rpsService := rps.New(rpsRepo)
|
||||||
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, projectsService, rpsService)
|
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService)
|
||||||
|
|
||||||
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services)
|
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -122,3 +122,8 @@ CREATE TABLE IF NOT EXISTS schedule_messages (
|
|||||||
is_dm BOOLEAN NOT NULL DEFAULT TRUE,
|
is_dm BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
PRIMARY KEY (schedule_id, message_id)
|
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