Compare commits
8
Commits
ft/meetings
...
ft/music
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded31409e2 | ||
|
|
ae3d574c68 | ||
|
|
d7edfea45e | ||
|
|
db5d23c2ac | ||
|
|
a85ac23a6b | ||
|
|
d177114ddb | ||
|
|
10c2efef7d | ||
|
|
e33eea6d24 |
@@ -4,6 +4,8 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
|
github.com/disgoorg/disgolink/v3 v3.1.0
|
||||||
|
github.com/disgoorg/snowflake/v2 v2.0.3
|
||||||
github.com/fogleman/gg v1.3.0
|
github.com/fogleman/gg v1.3.0
|
||||||
github.com/jackc/pgx/v5 v5.8.0
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
@@ -11,8 +13,9 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/disgoorg/json v1.2.0 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/gorilla/websocket v1.4.2 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/disgoorg/disgolink/v3 v3.1.0 h1:IwhycUfvw87VbvvNSwBcdGMQNSFvNQ7/AQofmbp1NS8=
|
||||||
|
github.com/disgoorg/disgolink/v3 v3.1.0/go.mod h1:UjHfrC4NT4vzibG3GyqtY5l3aMzFwfkU+B3RiW3AQQ8=
|
||||||
|
github.com/disgoorg/json v1.2.0 h1:6e/j4BCfSHIvucG1cd7tJPAOp1RgnnMFSqkvZUtEd1Y=
|
||||||
|
github.com/disgoorg/json v1.2.0/go.mod h1:BHDwdde0rpQFDVsRLKhma6Y7fTbQKub/zdGO5O9NqqA=
|
||||||
|
github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzgvSI2Ro=
|
||||||
|
github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c=
|
||||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
|||||||
+26
-2
@@ -5,6 +5,7 @@ import (
|
|||||||
"velox-bot/internal/commands"
|
"velox-bot/internal/commands"
|
||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
"velox-bot/internal/events"
|
"velox-bot/internal/events"
|
||||||
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
@@ -16,9 +17,11 @@ type Bot struct {
|
|||||||
Commands []*discordgo.ApplicationCommand
|
Commands []*discordgo.ApplicationCommand
|
||||||
registeredCommands []*discordgo.ApplicationCommand
|
registeredCommands []*discordgo.ApplicationCommand
|
||||||
Services *services.Services
|
Services *services.Services
|
||||||
|
LavalinkHost string
|
||||||
|
LavalinkPass string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) {
|
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) {
|
||||||
session, err := discordgo.New("Bot " + token)
|
session, err := discordgo.New("Bot " + token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -28,7 +31,13 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand,
|
|||||||
// - InteractionCreate (slash commands): Guilds
|
// - InteractionCreate (slash commands): Guilds
|
||||||
// - MessageCreate (leveling): GuildMessages
|
// - MessageCreate (leveling): GuildMessages
|
||||||
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
// - 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{
|
return &Bot{
|
||||||
Session: session,
|
Session: session,
|
||||||
@@ -36,6 +45,8 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand,
|
|||||||
GuildID: guildID,
|
GuildID: guildID,
|
||||||
Commands: cmds,
|
Commands: cmds,
|
||||||
Services: services,
|
Services: services,
|
||||||
|
LavalinkHost: lavalinkHost,
|
||||||
|
LavalinkPass: lavalinkPass,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +55,8 @@ func (b *Bot) Start() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass)
|
||||||
|
|
||||||
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
|
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
|
||||||
for _, cmd := range b.Commands {
|
for _, cmd := range b.Commands {
|
||||||
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
||||||
@@ -62,8 +75,19 @@ func (b *Bot) Start() error {
|
|||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
||||||
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
||||||
|
music.OnVoiceStateUpdate(vs)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.VoiceServerUpdate) {
|
||||||
|
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)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ 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"
|
||||||
|
cmdmusic "velox-bot/internal/commands/music"
|
||||||
|
"velox-bot/internal/commands/projects"
|
||||||
|
"velox-bot/internal/commands/schedule"
|
||||||
|
"velox-bot/internal/commands/timezone"
|
||||||
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
@@ -22,7 +27,13 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
|||||||
cmdlevel.Rank,
|
cmdlevel.Rank,
|
||||||
cmdlevel.Leaderboard,
|
cmdlevel.Leaderboard,
|
||||||
cmdlevel.Rewards,
|
cmdlevel.Rewards,
|
||||||
|
cmdmusic.Play,
|
||||||
|
cmdmusic.Queue,
|
||||||
|
cmdmusic.Volume,
|
||||||
meeting.Meeting,
|
meeting.Meeting,
|
||||||
|
timezone.Timezone,
|
||||||
|
schedule.Schedule,
|
||||||
|
projects.Projects,
|
||||||
}
|
}
|
||||||
|
|
||||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||||
@@ -39,10 +50,24 @@ 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,
|
||||||
|
"projects": projects.ProjectsHandler,
|
||||||
|
"play": cmdmusic.PlayHandler,
|
||||||
|
"queue": cmdmusic.QueueHandler,
|
||||||
|
"volume": cmdmusic.VolumeHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
switch i.Type {
|
||||||
|
case discordgo.InteractionApplicationCommand:
|
||||||
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
|
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
|
||||||
h(s, i)
|
h(s, i)
|
||||||
}
|
}
|
||||||
|
case discordgo.InteractionMessageComponent:
|
||||||
|
data := i.MessageComponentData()
|
||||||
|
if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" {
|
||||||
|
music.HandleComponent(s, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import "github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
|
func memberHasDJRole(s *discordgo.Session, guildID string, m *discordgo.Member) bool {
|
||||||
|
if s == nil || guildID == "" || m == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(m.Roles) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := s.GuildRoles(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
djRoleID := ""
|
||||||
|
for _, r := range roles {
|
||||||
|
if r != nil && r.Name == "DJ" {
|
||||||
|
djRoleID = r.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if djRoleID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rid := range m.Roles {
|
||||||
|
if rid == djRoleID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Play = &discordgo.ApplicationCommand{
|
||||||
|
Name: "play",
|
||||||
|
Description: "Play a song via Lavalink",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Name: "query",
|
||||||
|
Description: "Song name or URL",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := data.Options[0].StringValue()
|
||||||
|
|
||||||
|
// Always acknowledge quickly to avoid "application did not respond".
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Searching...",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr("You need the **DJ** role to use music commands."),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user provided plain text, turn it into a YouTube search
|
||||||
|
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
|
||||||
|
query = "ytsearch:" + query
|
||||||
|
} else {
|
||||||
|
query = normalizeYouTubeRadioURL(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := entry.Track.Info.Title
|
||||||
|
author := entry.Track.Info.Author
|
||||||
|
length := entry.Track.Info.Length
|
||||||
|
|
||||||
|
if started {
|
||||||
|
// manager will post now-playing message & manage invalidating old buttons
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Starting: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Added to queue: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return new(v) }
|
||||||
|
|
||||||
|
// normalizeYouTubeRadioURL strips auto-generated "radio/mix" params like:
|
||||||
|
// https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 -> https://www.youtube.com/watch?v=ID
|
||||||
|
// It intentionally does NOT strip normal playlist URLs.
|
||||||
|
func normalizeYouTubeRadioURL(raw string) string {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.ToLower(u.Host)
|
||||||
|
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
q := u.Query()
|
||||||
|
v := q.Get("v")
|
||||||
|
if v == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
list := q.Get("list")
|
||||||
|
_, hasStartRadio := q["start_radio"]
|
||||||
|
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
u.RawQuery = url.Values{"v": []string{v}}.Encode()
|
||||||
|
u.Fragment = ""
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) {
|
||||||
|
g, err := s.State.Guild(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot find guild voice state")
|
||||||
|
}
|
||||||
|
for _, vs := range g.VoiceStates {
|
||||||
|
if vs.UserID == userID {
|
||||||
|
return vs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("you must be in a voice channel")
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Queue = &discordgo.ApplicationCommand{
|
||||||
|
Name: "queue",
|
||||||
|
Description: "Show the current music queue",
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You need the **DJ** role to use music commands.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current, rest := music.GetQueue(i.GuildID)
|
||||||
|
|
||||||
|
if current == nil {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "The queue is currently empty.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nowPlaying := fmt.Sprintf("%s — %s", current.Track.Info.Title, current.Track.Info.Author)
|
||||||
|
|
||||||
|
var descBuilder strings.Builder
|
||||||
|
if len(rest) > 0 {
|
||||||
|
limit := len(rest)
|
||||||
|
if limit > 10 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
for idx, entry := range rest[:limit] {
|
||||||
|
fmt.Fprintf(&descBuilder, "%d. %s — %s (requested by %s)\n", idx+1, entry.Track.Info.Title, entry.Track.Info.Author, entry.RequestedBy)
|
||||||
|
}
|
||||||
|
if len(rest) > limit {
|
||||||
|
fmt.Fprintf(&descBuilder, "...and %d more.", len(rest)-limit)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
descBuilder.WriteString("Nothing else in the queue.")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{
|
||||||
|
{
|
||||||
|
Title: "Music Queue",
|
||||||
|
Description: descBuilder.String(),
|
||||||
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
|
{
|
||||||
|
Name: "Now Playing",
|
||||||
|
Value: nowPlaying,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Volume = &discordgo.ApplicationCommand{
|
||||||
|
Name: "volume",
|
||||||
|
Description: "Set playback volume (0-150)",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionInteger,
|
||||||
|
Name: "value",
|
||||||
|
Description: "Volume percent (0-150)",
|
||||||
|
Required: true,
|
||||||
|
MinValue: ptrFloat(0),
|
||||||
|
MaxValue: 150,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Updating volume...",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr("You need the **DJ** role to use music commands."),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr("Missing volume value."),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vol := int(data.Options[0].IntValue())
|
||||||
|
if err := music.SetVolume(i.GuildID, vol); err != nil {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||||
|
Content: ptr(fmt.Sprintf("Volume set to **%d%%**.", vol)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrFloat(v float64) *float64 { return &v }
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"velox-bot/internal/commands/music/public"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Play *discordgo.ApplicationCommand = public.Play
|
||||||
|
Queue *discordgo.ApplicationCommand = public.Queue
|
||||||
|
Volume *discordgo.ApplicationCommand = public.Volume
|
||||||
|
)
|
||||||
|
|
||||||
|
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) }
|
||||||
|
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.QueueHandler(s, i)
|
||||||
|
}
|
||||||
|
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) }
|
||||||
|
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"velox-bot/internal/commands/projects/shared"
|
||||||
|
"velox-bot/internal/db/services"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Projects = &discordgo.ApplicationCommand{
|
||||||
|
Name: "projects",
|
||||||
|
Description: "Manage and list ongoing projects",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "list",
|
||||||
|
Description: "List active projects with creators and helpers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "create",
|
||||||
|
Description: "Create a new active project",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Name: "name",
|
||||||
|
Description: "Project name",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Name: "description",
|
||||||
|
Description: "Short description (optional)",
|
||||||
|
Required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "add-helper",
|
||||||
|
Description: "Add a helper to an existing project",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionInteger,
|
||||||
|
Name: "project-id",
|
||||||
|
Description: "ID of the project",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionUser,
|
||||||
|
Name: "user",
|
||||||
|
Description: "User to add as helper",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !shared.RequireGuild(s, i) || !shared.RequireProjectsService(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
handleList(s, i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch data.Options[0].Name {
|
||||||
|
case "list":
|
||||||
|
handleList(s, i)
|
||||||
|
case "create":
|
||||||
|
handleCreate(s, i)
|
||||||
|
case "add-helper":
|
||||||
|
handleAddHelper(s, i)
|
||||||
|
default:
|
||||||
|
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
guildID, ok := shared.ParseGuildID(s, i)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = 25
|
||||||
|
items, err := services.Global.Projects.ListActiveWithMembers(context.Background(), guildID, limit)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to load projects.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
shared.RespondEphemeral(s, i, "There are no active projects at the moment.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Active projects",
|
||||||
|
Description: "",
|
||||||
|
Color: 0x00bcd4,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil || item.Project == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := item.Project
|
||||||
|
name := p.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "Unnamed project"
|
||||||
|
}
|
||||||
|
creatorMention := mentionUser(item.Creator)
|
||||||
|
|
||||||
|
var helpersMentions []string
|
||||||
|
for _, h := range item.Helpers {
|
||||||
|
helpersMentions = append(helpersMentions, mentionUser(h))
|
||||||
|
}
|
||||||
|
helpersText := "None"
|
||||||
|
if len(helpersMentions) > 0 {
|
||||||
|
helpersText = strings.Join(helpersMentions, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := "**Creator:** " + creatorMention + "\n" +
|
||||||
|
"**Helpers:** " + helpersText
|
||||||
|
if p.Description != "" {
|
||||||
|
desc += "\n" + p.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||||
|
Name: name,
|
||||||
|
Value: desc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{embed},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mentionUser(id int64) string {
|
||||||
|
if id == 0 {
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
return "<@" + strconv.FormatInt(id, 10) + ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !shared.RequireManageGuild(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 name, description string
|
||||||
|
for _, o := range opt.Options {
|
||||||
|
switch o.Name {
|
||||||
|
case "name":
|
||||||
|
name = o.StringValue()
|
||||||
|
case "description":
|
||||||
|
description = o.StringValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
shared.RespondEphemeral(s, i, "Project name cannot be empty.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to create project.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !shared.RequireManageGuild(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID, ok := shared.ParseGuildID(s, i)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
shared.RespondEphemeral(s, i, "Missing options.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opt := data.Options[0]
|
||||||
|
|
||||||
|
var projectID int64
|
||||||
|
var userID string
|
||||||
|
for _, o := range opt.Options {
|
||||||
|
switch o.Name {
|
||||||
|
case "project-id":
|
||||||
|
projectID = o.IntValue()
|
||||||
|
case "user":
|
||||||
|
if u := o.UserValue(s); u != nil {
|
||||||
|
userID = u.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectID <= 0 {
|
||||||
|
shared.RespondEphemeral(s, i, "Invalid project ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := services.Global.Projects.ProjectExistsInGuild(context.Background(), guildID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to load project.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
shared.RespondEphemeral(s, i, "Project `"+strconv.FormatInt(projectID, 10)+"` not found in this server.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID64, err := strconv.ParseInt(userID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.")
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"velox-bot/internal/commands/projects/public"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Projects *discordgo.ApplicationCommand = public.Projects
|
||||||
|
)
|
||||||
|
|
||||||
|
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) }
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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 RequireProjectsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||||
|
if services.Global == nil || services.Global.Projects == nil {
|
||||||
|
RespondEphemeral(s, i, "Projects service is not available.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||||
|
if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 {
|
||||||
|
RespondEphemeral(s, i, "You don't have permission to manage projects.")
|
||||||
|
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,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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ type Config struct {
|
|||||||
AppID string
|
AppID string
|
||||||
GuildID string
|
GuildID string
|
||||||
DBHost string
|
DBHost string
|
||||||
|
LavalinkHost string
|
||||||
|
LavalinkPass string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*Config, error) {
|
func LoadConfig() (*Config, error) {
|
||||||
@@ -39,10 +41,15 @@ func LoadConfig() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("DB_HOST is not set")
|
return nil, fmt.Errorf("DB_HOST is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lavalinkHost := os.Getenv("LAVALINK_HOST")
|
||||||
|
lavalinkPass := os.Getenv("LAVALINK_PASSWORD")
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
BotToken: token,
|
BotToken: token,
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
GuildID: guildID,
|
GuildID: guildID,
|
||||||
DBHost: dbHost,
|
DBHost: dbHost,
|
||||||
|
LavalinkHost: lavalinkHost,
|
||||||
|
LavalinkPass: lavalinkPass,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package projectsrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ID int64
|
||||||
|
GuildID int64
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
IsActive bool
|
||||||
|
CreatedBy int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectWithMembers struct {
|
||||||
|
Project *Project
|
||||||
|
Creator int64
|
||||||
|
Helpers []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepo(db *sql.DB) *Repo {
|
||||||
|
return &Repo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO projects (guild_id, name, description, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
var id int64
|
||||||
|
if err := r.db.QueryRowContext(ctx, q, guildID, name, description, creatorID).Scan(&id); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const qMember = `
|
||||||
|
INSERT INTO project_members (project_id, user_id, is_creator)
|
||||||
|
VALUES ($1, $2, TRUE)
|
||||||
|
ON CONFLICT (project_id, user_id) DO NOTHING
|
||||||
|
`
|
||||||
|
if _, err := r.db.ExecContext(ctx, qMember, id, creatorID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) AddHelper(ctx context.Context, projectID, userID int64) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO project_members (project_id, user_id, is_creator)
|
||||||
|
VALUES ($1, $2, FALSE)
|
||||||
|
ON CONFLICT (project_id, user_id) DO NOTHING
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, projectID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT 1
|
||||||
|
FROM projects
|
||||||
|
WHERE guild_id = $1 AND id = $2
|
||||||
|
`
|
||||||
|
row := r.db.QueryRowContext(ctx, q, guildID, projectID)
|
||||||
|
var one int
|
||||||
|
switch err := row.Scan(&one); err {
|
||||||
|
case nil:
|
||||||
|
return true, nil
|
||||||
|
case sql.ErrNoRows:
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListActiveProjectsWithMembers(ctx context.Context, guildID int64, limit int) ([]*ProjectWithMembers, error) {
|
||||||
|
const qProjects = `
|
||||||
|
SELECT id, name, description, created_by
|
||||||
|
FROM projects
|
||||||
|
WHERE guild_id = $1 AND is_active = TRUE
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
rows, err := r.db.QueryContext(ctx, qProjects, guildID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []*ProjectWithMembers
|
||||||
|
for rows.Next() {
|
||||||
|
p := &Project{GuildID: guildID, IsActive: true}
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, &ProjectWithMembers{
|
||||||
|
Project: p,
|
||||||
|
Creator: p.CreatedBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load members per project.
|
||||||
|
const qMembers = `
|
||||||
|
SELECT project_id, user_id, is_creator
|
||||||
|
FROM project_members
|
||||||
|
WHERE project_id = ANY($1)
|
||||||
|
`
|
||||||
|
ids := make([]int64, len(out))
|
||||||
|
for i, p := range out {
|
||||||
|
ids[i] = p.Project.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsM, err := r.db.QueryContext(ctx, qMembers, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rowsM.Close()
|
||||||
|
|
||||||
|
index := make(map[int64]*ProjectWithMembers, len(out))
|
||||||
|
for _, p := range out {
|
||||||
|
index[p.Project.ID] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
for rowsM.Next() {
|
||||||
|
var pid, uid int64
|
||||||
|
var isCreator bool
|
||||||
|
if err := rowsM.Scan(&pid, &uid, &isCreator); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item, ok := index[pid]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isCreator {
|
||||||
|
item.Creator = uid
|
||||||
|
} else {
|
||||||
|
item.Helpers = append(item.Helpers, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rowsM.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,32 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/projectsrepo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *projectsrepo.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repo *projectsrepo.Repo) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) {
|
||||||
|
return s.repo.CreateProject(ctx, guildID, creatorID, name, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AddHelper(ctx context.Context, projectID, userID int64) error {
|
||||||
|
return s.repo.AddHelper(ctx, projectID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListActiveWithMembers(ctx context.Context, guildID int64, limit int) ([]*projectsrepo.ProjectWithMembers, error) {
|
||||||
|
return s.repo.ListActiveProjectsWithMembers(ctx, guildID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
|
||||||
|
return s.repo.ProjectExistsInGuild(ctx, guildID, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,9 @@ import (
|
|||||||
"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/usersettings"
|
||||||
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,16 +14,22 @@ type Services struct {
|
|||||||
Level *level.Service
|
Level *level.Service
|
||||||
LevelSettings *levelsettings.Service
|
LevelSettings *levelsettings.Service
|
||||||
Meeting *meeting.Service
|
Meeting *meeting.Service
|
||||||
|
Schedule *schedule.Service
|
||||||
|
UserSettings *usersettings.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, 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,
|
||||||
|
UserSettings: userSettings,
|
||||||
|
Projects: projects,
|
||||||
RPS: rps,
|
RPS: rps,
|
||||||
}
|
}
|
||||||
Global = s
|
Global = s
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
// DJ role is required for all music controls.
|
||||||
|
if !memberHasDJRoleForComponents(s, i.GuildID, i.Member) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "You need the **DJ** role to use music commands.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
customID := i.MessageComponentData().CustomID
|
||||||
|
parts := strings.Split(customID, ":")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := parts[1]
|
||||||
|
guildID := i.GuildID
|
||||||
|
|
||||||
|
// Ignore controls from old now-playing messages
|
||||||
|
currentMsgID := CurrentNowPlayingMessageID(guildID)
|
||||||
|
if currentMsgID != "" && i.Message != nil && i.Message.ID != currentMsgID {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "These controls are outdated.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledge immediately to avoid "This interaction failed".
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseDeferredMessageUpdate,
|
||||||
|
})
|
||||||
|
|
||||||
|
var err error
|
||||||
|
content := ""
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "pause":
|
||||||
|
err = Pause(guildID, true)
|
||||||
|
content = "Paused."
|
||||||
|
case "resume":
|
||||||
|
err = Pause(guildID, false)
|
||||||
|
content = "Resumed."
|
||||||
|
case "toggle_pause":
|
||||||
|
paused, _ := pausedAndVolume(guildID)
|
||||||
|
err = Pause(guildID, !paused)
|
||||||
|
if paused {
|
||||||
|
content = "Resumed."
|
||||||
|
} else {
|
||||||
|
content = "Paused."
|
||||||
|
}
|
||||||
|
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||||
|
case "skip":
|
||||||
|
err = Skip(guildID)
|
||||||
|
content = "Skipped."
|
||||||
|
case "stop":
|
||||||
|
err = Stop(guildID)
|
||||||
|
content = "Stopped."
|
||||||
|
case "repeat_song":
|
||||||
|
rs, rq := ToggleRepeatSong(guildID)
|
||||||
|
_ = rs
|
||||||
|
_ = rq
|
||||||
|
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||||
|
if rs {
|
||||||
|
content = "Repeat song enabled."
|
||||||
|
} else {
|
||||||
|
content = "Repeat song disabled."
|
||||||
|
}
|
||||||
|
case "repeat_queue":
|
||||||
|
rs, rq := ToggleRepeatQueue(guildID)
|
||||||
|
_ = rs
|
||||||
|
_ = rq
|
||||||
|
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||||
|
if rq {
|
||||||
|
content = "Repeat queue enabled."
|
||||||
|
} else {
|
||||||
|
content = "Repeat queue disabled."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
content = "Done."
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
content = "Error: " + err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// send ephemeral confirmation
|
||||||
|
_, _ = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
|
||||||
|
Content: content,
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/disgoorg/disgolink/v3/disgolink"
|
||||||
|
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||||
|
"github.com/disgoorg/snowflake/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackEntry struct {
|
||||||
|
Track lavalink.Track
|
||||||
|
RequestedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type guildPlayer struct {
|
||||||
|
Player disgolink.Player
|
||||||
|
Queue []TrackEntry
|
||||||
|
RepeatSong bool
|
||||||
|
RepeatQueue bool
|
||||||
|
Paused bool
|
||||||
|
Volume int
|
||||||
|
IdleSince time.Time
|
||||||
|
TextChannelID string
|
||||||
|
NowPlayingMsgID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
client disgolink.Client
|
||||||
|
session *discordgo.Session
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
players map[string]*guildPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
var manager *Manager
|
||||||
|
|
||||||
|
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error {
|
||||||
|
if lavalinkHost == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := snowflake.Parse(appID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse app id for lavalink: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Manager{
|
||||||
|
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
|
||||||
|
session: session,
|
||||||
|
players: make(map[string]*guildPlayer),
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(lavalinkHost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse lavalink host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secure := u.Scheme == "https" || u.Scheme == "wss"
|
||||||
|
password := lavalinkPass
|
||||||
|
if password == "" {
|
||||||
|
password = "youshallnotpass"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{
|
||||||
|
Name: "main",
|
||||||
|
Address: u.Host,
|
||||||
|
Password: password,
|
||||||
|
Secure: secure,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add lavalink node: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = m
|
||||||
|
go idleDisconnectLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func idleDisconnectLoop() {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
if manager == nil || manager.session == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var toDisconnect []string
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
for guildID, gp := range manager.players {
|
||||||
|
if gp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're paused or have anything queued/playing, clear idle timer.
|
||||||
|
if gp.Paused || len(gp.Queue) > 0 {
|
||||||
|
gp.IdleSince = time.Time{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if gp.IdleSince.IsZero() {
|
||||||
|
gp.IdleSince = now
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Sub(gp.IdleSince) >= time.Minute {
|
||||||
|
toDisconnect = append(toDisconnect, guildID)
|
||||||
|
gp.IdleSince = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
for _, guildID := range toDisconnect {
|
||||||
|
// Best-effort: disconnect. Safe even if we're already disconnected.
|
||||||
|
_ = manager.session.ChannelVoiceJoinManual(guildID, "", false, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func OnVoiceStateUpdate(vs *discordgo.VoiceStateUpdate) {
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if vs.UserID != manager.session.State.User.ID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID := snowflake.MustParse(vs.GuildID)
|
||||||
|
var channelID *snowflake.ID
|
||||||
|
if vs.ChannelID != "" {
|
||||||
|
id := snowflake.MustParse(vs.ChannelID)
|
||||||
|
channelID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.client.OnVoiceStateUpdate(
|
||||||
|
context.Background(),
|
||||||
|
guildID,
|
||||||
|
channelID,
|
||||||
|
vs.SessionID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OnVoiceServerUpdate(ev *discordgo.VoiceServerUpdate) {
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endpoint := ""
|
||||||
|
if ev.Endpoint != "" {
|
||||||
|
endpoint = ev.Endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.client.OnVoiceServerUpdate(
|
||||||
|
context.Background(),
|
||||||
|
snowflake.MustParse(ev.GuildID),
|
||||||
|
ev.Token,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrCreateGuildPlayer(guildID string) *guildPlayer {
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
|
||||||
|
if gp, ok := manager.players[guildID]; ok {
|
||||||
|
return gp
|
||||||
|
}
|
||||||
|
|
||||||
|
player := manager.client.Player(snowflake.MustParse(guildID))
|
||||||
|
gp := &guildPlayer{
|
||||||
|
Player: player,
|
||||||
|
Queue: make([]TrackEntry, 0),
|
||||||
|
Volume: 100,
|
||||||
|
}
|
||||||
|
manager.players[guildID] = gp
|
||||||
|
return gp
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) {
|
||||||
|
if manager == nil {
|
||||||
|
return nil, false, fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join voice channel
|
||||||
|
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("join voice channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
if textChannelID != "" {
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp.TextChannelID = textChannelID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadedTracks []lavalink.Track
|
||||||
|
|
||||||
|
manager.client.BestNode().LoadTracksHandler(
|
||||||
|
context.Background(),
|
||||||
|
query,
|
||||||
|
disgolink.NewResultHandler(
|
||||||
|
func(track lavalink.Track) {
|
||||||
|
loadedTracks = append(loadedTracks, track)
|
||||||
|
},
|
||||||
|
func(playlist lavalink.Playlist) {
|
||||||
|
if len(playlist.Tracks) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect Lavalink's selectedTrack when present by rotating the playlist.
|
||||||
|
selected := playlist.Info.SelectedTrack
|
||||||
|
if selected >= 0 && selected < len(playlist.Tracks) {
|
||||||
|
loadedTracks = append(loadedTracks, playlist.Tracks[selected:]...)
|
||||||
|
loadedTracks = append(loadedTracks, playlist.Tracks[:selected]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedTracks = append(loadedTracks, playlist.Tracks...)
|
||||||
|
},
|
||||||
|
func(tracks []lavalink.Track) {
|
||||||
|
if len(tracks) > 0 {
|
||||||
|
loadedTracks = append(loadedTracks, tracks[0])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func() {
|
||||||
|
},
|
||||||
|
func(err error) {
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(loadedTracks) == 0 {
|
||||||
|
return nil, false, fmt.Errorf("no tracks found for query")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some sources may return unplayable entries; pick the first with an encoded track.
|
||||||
|
firstPlayableIdx := 0
|
||||||
|
for idx := range loadedTracks {
|
||||||
|
if loadedTracks[idx].Encoded != "" {
|
||||||
|
firstPlayableIdx = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstPlayableIdx != 0 && firstPlayableIdx < len(loadedTracks) {
|
||||||
|
loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]TrackEntry, len(loadedTracks))
|
||||||
|
for idx, t := range loadedTracks {
|
||||||
|
entries[idx] = TrackEntry{
|
||||||
|
Track: t,
|
||||||
|
RequestedBy: requester,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
firstEntry := entries[0]
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
shouldStart := len(gp.Queue) == 0
|
||||||
|
gp.Queue = append(gp.Queue, entries...)
|
||||||
|
gp.IdleSince = time.Time{}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if shouldStart {
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("start track: %w", err)
|
||||||
|
}
|
||||||
|
if gp.TextChannelID != "" {
|
||||||
|
postNowPlaying(guildID, gp.TextChannelID, firstEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &firstEntry, shouldStart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !event.Reason.MayStartNext() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID := player.GuildID().String()
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp, ok := manager.players[guildID]
|
||||||
|
if !ok || len(gp.Queue) == 0 {
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat current track
|
||||||
|
if gp.RepeatSong {
|
||||||
|
next := gp.Queue[0]
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if textChannelID != "" {
|
||||||
|
postNowPlaying(guildID, textChannelID, next)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// pop current and optionally cycle it to end
|
||||||
|
finished := gp.Queue[0]
|
||||||
|
gp.Queue = gp.Queue[1:]
|
||||||
|
if gp.RepeatQueue {
|
||||||
|
gp.Queue = append(gp.Queue, finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gp.Queue) == 0 {
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||||
|
gp.NowPlayingMsgID = ""
|
||||||
|
gp.IdleSince = time.Now()
|
||||||
|
manager.mu.Unlock()
|
||||||
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||||
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||||
|
}
|
||||||
|
_ = gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next := gp.Queue[0]
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if textChannelID != "" {
|
||||||
|
postNowPlaying(guildID, textChannelID, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Pause(guildID string, pause bool) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp.Paused = pause
|
||||||
|
if pause {
|
||||||
|
gp.IdleSince = time.Time{}
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Skip(guildID string) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
if len(gp.Queue) == 0 {
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// skip always advances regardless of repeat song
|
||||||
|
skipped := gp.Queue[0]
|
||||||
|
gp.Queue = gp.Queue[1:]
|
||||||
|
if gp.RepeatQueue {
|
||||||
|
gp.Queue = append(gp.Queue, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gp.Queue) == 0 {
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||||
|
gp.NowPlayingMsgID = ""
|
||||||
|
gp.IdleSince = time.Now()
|
||||||
|
manager.mu.Unlock()
|
||||||
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||||
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||||
|
}
|
||||||
|
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||||
|
}
|
||||||
|
|
||||||
|
next := gp.Queue[0]
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if textChannelID != "" {
|
||||||
|
postNowPlaying(guildID, textChannelID, next)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop(guildID string) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||||
|
gp.Queue = nil
|
||||||
|
gp.Paused = false
|
||||||
|
gp.NowPlayingMsgID = ""
|
||||||
|
gp.IdleSince = time.Now()
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||||
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQueue(guildID string) (*TrackEntry, []TrackEntry) {
|
||||||
|
if manager == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
|
||||||
|
if len(gp.Queue) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
current := gp.Queue[0]
|
||||||
|
rest := make([]TrackEntry, len(gp.Queue)-1)
|
||||||
|
copy(rest, gp.Queue[1:])
|
||||||
|
return ¤t, rest
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToggleRepeatSong(guildID string) (bool, bool) {
|
||||||
|
if manager == nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
gp.RepeatSong = !gp.RepeatSong
|
||||||
|
if gp.RepeatSong {
|
||||||
|
gp.RepeatQueue = false
|
||||||
|
}
|
||||||
|
return gp.RepeatSong, gp.RepeatQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
||||||
|
if manager == nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
gp.RepeatQueue = !gp.RepeatQueue
|
||||||
|
if gp.RepeatQueue {
|
||||||
|
gp.RepeatSong = false
|
||||||
|
}
|
||||||
|
return gp.RepeatSong, gp.RepeatQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func repeatFlags(guildID string) (bool, bool) {
|
||||||
|
if manager == nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
return gp.RepeatSong, gp.RepeatQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func pausedAndVolume(guildID string) (bool, int) {
|
||||||
|
if manager == nil {
|
||||||
|
return false, 100
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
vol := gp.Volume
|
||||||
|
if vol <= 0 {
|
||||||
|
vol = 100
|
||||||
|
}
|
||||||
|
return gp.Paused, vol
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetVolume(guildID string, volume int) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
if volume < 0 {
|
||||||
|
volume = 0
|
||||||
|
}
|
||||||
|
if volume > 150 {
|
||||||
|
volume = 150
|
||||||
|
}
|
||||||
|
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp.Volume = volume
|
||||||
|
textChannelID := gp.TextChannelID
|
||||||
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if err := gp.Player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the embed so the displayed volume matches.
|
||||||
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||||
|
current, _ := GetQueue(guildID)
|
||||||
|
if current != nil {
|
||||||
|
postNowPlaying(guildID, textChannelID, *current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CurrentNowPlayingMessageID(guildID string) string {
|
||||||
|
if manager == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
return gp.NowPlayingMsgID
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||||
|
"github.com/disgoorg/snowflake/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nowPlayingComponents(disabled bool, repeatSong bool, repeatQueue bool, paused bool) []discordgo.MessageComponent {
|
||||||
|
repeatSongStyle := discordgo.SecondaryButton
|
||||||
|
if repeatSong {
|
||||||
|
repeatSongStyle = discordgo.SuccessButton
|
||||||
|
}
|
||||||
|
repeatQueueStyle := discordgo.SecondaryButton
|
||||||
|
if repeatQueue {
|
||||||
|
repeatQueueStyle = discordgo.SuccessButton
|
||||||
|
}
|
||||||
|
|
||||||
|
return []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.Button{
|
||||||
|
Style: discordgo.SecondaryButton,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: map[bool]string{true: "▶️", false: "⏸️"}[paused]},
|
||||||
|
CustomID: "music:toggle_pause",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Style: discordgo.SecondaryButton,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: "⏭️"},
|
||||||
|
CustomID: "music:skip",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Style: repeatSongStyle,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: "🔂"},
|
||||||
|
CustomID: "music:repeat_song",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Style: repeatQueueStyle,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: "🔁"},
|
||||||
|
CustomID: "music:repeat_queue",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
discordgo.Button{
|
||||||
|
Style: discordgo.DangerButton,
|
||||||
|
Emoji: &discordgo.ComponentEmoji{Name: "✖️"},
|
||||||
|
CustomID: "music:stop",
|
||||||
|
Disabled: disabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableNowPlaying(textChannelID, messageID string) {
|
||||||
|
if manager == nil || textChannelID == "" || messageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disabled := nowPlayingComponents(true, false, false, false)
|
||||||
|
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||||
|
Channel: textChannelID,
|
||||||
|
ID: messageID,
|
||||||
|
Components: &disabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func postNowPlaying(guildID, textChannelID string, entry TrackEntry) {
|
||||||
|
if manager == nil || textChannelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp, ok := manager.players[guildID]
|
||||||
|
var oldMsgID string
|
||||||
|
if ok {
|
||||||
|
oldMsgID = gp.NowPlayingMsgID
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
if oldMsgID != "" {
|
||||||
|
disableNowPlaying(textChannelID, oldMsgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
repeatSong, repeatQueue := repeatFlags(guildID)
|
||||||
|
paused, volume := pausedAndVolume(guildID)
|
||||||
|
|
||||||
|
info := entry.Track.Info
|
||||||
|
title := info.Title
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Author: &discordgo.MessageEmbedAuthor{
|
||||||
|
Name: info.Author,
|
||||||
|
IconURL: sourceIconURL(info.SourceName),
|
||||||
|
},
|
||||||
|
Title: title,
|
||||||
|
Color: 0x2B2D31, // matches Discord dark-ish embed accent
|
||||||
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
|
{Name: "Duration", Value: formatDuration(info.Length, info.IsStream), Inline: true},
|
||||||
|
{Name: "Volume", Value: fmt.Sprintf("%d%%", volume), Inline: true},
|
||||||
|
{Name: "Requested by", Value: entry.RequestedBy, Inline: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.URI != nil && *info.URI != "" {
|
||||||
|
embed.URL = *info.URI
|
||||||
|
}
|
||||||
|
|
||||||
|
if art := artworkURL(info); art != "" {
|
||||||
|
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: art}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := manager.session.ChannelMessageSendComplex(textChannelID, &discordgo.MessageSend{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{embed},
|
||||||
|
Components: nowPlayingComponents(false, repeatSong, repeatQueue, paused),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
// Avoid calling getOrCreateGuildPlayer while holding manager.mu (it also locks).
|
||||||
|
gp = manager.players[guildID]
|
||||||
|
if gp == nil {
|
||||||
|
gp = &guildPlayer{
|
||||||
|
Player: manager.client.Player(snowflake.MustParse(guildID)),
|
||||||
|
Queue: make([]TrackEntry, 0),
|
||||||
|
}
|
||||||
|
manager.players[guildID] = gp
|
||||||
|
}
|
||||||
|
gp.TextChannelID = textChannelID
|
||||||
|
gp.NowPlayingMsgID = msg.ID
|
||||||
|
manager.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func artworkURL(info lavalink.TrackInfo) string {
|
||||||
|
if info.ArtworkURL != nil && *info.ArtworkURL != "" {
|
||||||
|
return *info.ArtworkURL
|
||||||
|
}
|
||||||
|
// Lavalink sets Identifier for YouTube videos; use standard thumbnail as fallback.
|
||||||
|
if strings.EqualFold(info.SourceName, "youtube") && info.Identifier != "" {
|
||||||
|
return "https://img.youtube.com/vi/" + info.Identifier + "/hqdefault.jpg"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d lavalink.Duration, isStream bool) string {
|
||||||
|
if isStream {
|
||||||
|
return "Live"
|
||||||
|
}
|
||||||
|
secs := d.Seconds()
|
||||||
|
if secs < 0 {
|
||||||
|
secs = 0
|
||||||
|
}
|
||||||
|
h := secs / 3600
|
||||||
|
m := (secs % 3600) / 60
|
||||||
|
s := secs % 60
|
||||||
|
if h > 0 {
|
||||||
|
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%02d", m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceIconURL(source string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||||
|
case "youtube":
|
||||||
|
// Stable PNG favicon (Discord doesn't render .ico/.svg reliably in embeds)
|
||||||
|
return "https://www.google.com/s2/favicons?sz=64&domain=youtube.com"
|
||||||
|
case "soundcloud":
|
||||||
|
return "https://www.google.com/s2/favicons?sz=64&domain=soundcloud.com"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import "github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
|
func memberHasDJRoleForComponents(s *discordgo.Session, guildID string, m *discordgo.Member) bool {
|
||||||
|
if s == nil || guildID == "" || m == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(m.Roles) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := s.GuildRoles(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
djRoleID := ""
|
||||||
|
for _, r := range roles {
|
||||||
|
if r != nil && r.Name == "DJ" {
|
||||||
|
djRoleID = r.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if djRoleID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rid := range m.Roles {
|
||||||
|
if rid == djRoleID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package music
|
||||||
|
|
||||||
|
import "github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
|
func updateNowPlayingButtons(channelID, messageID string, guildID string) {
|
||||||
|
if manager == nil || channelID == "" || messageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repeatSong, repeatQueue := repeatFlags(guildID)
|
||||||
|
paused, _ := pausedAndVolume(guildID)
|
||||||
|
comps := nowPlayingComponents(false, repeatSong, repeatQueue, paused)
|
||||||
|
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||||
|
Channel: channelID,
|
||||||
|
ID: messageID,
|
||||||
|
Components: &comps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,12 +11,18 @@ import (
|
|||||||
"velox-bot/internal/config"
|
"velox-bot/internal/config"
|
||||||
"velox-bot/internal/db"
|
"velox-bot/internal/db"
|
||||||
"velox-bot/internal/db/repos/levelrepo"
|
"velox-bot/internal/db/repos/levelrepo"
|
||||||
|
"velox-bot/internal/db/repos/projectsrepo"
|
||||||
"velox-bot/internal/db/repos/rpsrepo"
|
"velox-bot/internal/db/repos/rpsrepo"
|
||||||
|
"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/usersettings"
|
||||||
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,13 +43,19 @@ func main() {
|
|||||||
levelRepo := levelrepo.NewRepo(db)
|
levelRepo := levelrepo.NewRepo(db)
|
||||||
settingsRepo := settingsrepo.NewRepo(db)
|
settingsRepo := settingsrepo.NewRepo(db)
|
||||||
rpsRepo := rpsrepo.NewRepo(db)
|
rpsRepo := rpsrepo.NewRepo(db)
|
||||||
|
projectsRepo := projectsrepo.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)
|
||||||
|
userSettingsService := usersettings.New(userSettingsRepo)
|
||||||
|
projectsService := projects.New(projectsRepo)
|
||||||
rpsService := rps.New(rpsRepo)
|
rpsService := rps.New(rpsRepo)
|
||||||
services := services.NewServices(levelService, levelSettingsService, meetingService, 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, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error creating bot: %v", err)
|
log.Fatalf("Error creating bot: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
+44
@@ -82,4 +82,48 @@ CREATE TABLE IF NOT EXISTS meeting_temp_channels (
|
|||||||
PRIMARY KEY (guild_id, channel_id)
|
PRIMARY KEY (guild_id, channel_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Projects & members
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_members (
|
||||||
|
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
is_creator BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
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