diff --git a/internal/bot/bot.go b/internal/bot/bot.go index ed50da0..225c29c 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -24,6 +24,12 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand, return nil, err } + // Intents needed for: + // - InteractionCreate (slash commands): Guilds + // - MessageCreate (leveling): GuildMessages + // - VoiceStateUpdate (meeting lobby): GuildVoiceStates + session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsGuildVoiceStates + return &Bot{ Session: session, AppID: appID, @@ -54,6 +60,10 @@ func (b *Bot) Start() error { events.HandleMessageCreate(s, m, b.Services) }) + b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) { + events.HandleVoiceStateUpdate(s, vs, b.Services) + }) + return nil } diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 7bc2b3f..a0b84f4 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -4,6 +4,7 @@ import ( "velox-bot/internal/commands/fun" "velox-bot/internal/commands/help" cmdlevel "velox-bot/internal/commands/level" + "velox-bot/internal/commands/meeting" "github.com/bwmarrin/discordgo" ) @@ -21,6 +22,7 @@ var AllCommands = []*discordgo.ApplicationCommand{ cmdlevel.Rank, cmdlevel.Leaderboard, cmdlevel.Rewards, + meeting.Meeting, } var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ @@ -36,6 +38,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre "rank": cmdlevel.RankHandler, "leaderboard": cmdlevel.LeaderboardHandler, "rewards": cmdlevel.RewardsHandler, + "meeting": meeting.MeetingHandler, } func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { diff --git a/internal/commands/meeting/public/meeting.go b/internal/commands/meeting/public/meeting.go new file mode 100644 index 0000000..c34e2bb --- /dev/null +++ b/internal/commands/meeting/public/meeting.go @@ -0,0 +1,85 @@ +package public + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/meeting/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Meeting = &discordgo.ApplicationCommand{ + Name: "meeting", + Description: "Meeting room settings", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set-lobby", + Description: "Set the voice lobby channel used to create meetings", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Voice channel lobby", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice}, + }, + }, + }, + }, +} + +func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) || !shared.RequireManageGuild(s, i) || !shared.RequireMeetingService(s, i) { + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Missing subcommand.") + return + } + + switch data.Options[0].Name { + case "set-lobby": + handleSetLobby(s, i) + default: + shared.RespondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handleSetLobby(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + channelOpt, ok := optMap["channel"] + if !ok { + shared.RespondEphemeral(s, i, "Missing channel option.") + return + } + + ch := channelOpt.ChannelValue(s) + if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice { + shared.RespondEphemeral(s, i, "Invalid voice channel.") + return + } + + channelID, err := strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid channel ID.") + return + } + + if err := services.Global.Meeting.SetLobbyChannel(context.Background(), guildID, channelID); err != nil { + shared.RespondEphemeral(s, i, "Failed to set meeting lobby.") + return + } + + shared.RespondEphemeral(s, i, "Meeting lobby set to "+ch.Mention()+".") +} + diff --git a/internal/commands/meeting/registry.go b/internal/commands/meeting/registry.go new file mode 100644 index 0000000..f5c630a --- /dev/null +++ b/internal/commands/meeting/registry.go @@ -0,0 +1,14 @@ +package meeting + +import ( + "velox-bot/internal/commands/meeting/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Meeting *discordgo.ApplicationCommand = public.Meeting +) + +func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.MeetingHandler(s, i) } + diff --git a/internal/commands/meeting/shared/shared.go b/internal/commands/meeting/shared/shared.go new file mode 100644 index 0000000..791055f --- /dev/null +++ b/internal/commands/meeting/shared/shared.go @@ -0,0 +1,66 @@ +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 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 meeting settings.") + return false + } + return true +} + +func RequireMeetingService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if services.Global == nil || services.Global.Meeting == nil { + RespondEphemeral(s, i, "Meeting 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 +} + +func SubcommandOptionMap(i *discordgo.InteractionCreate) map[string]*discordgo.ApplicationCommandInteractionDataOption { + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + return map[string]*discordgo.ApplicationCommandInteractionDataOption{} + } + opts := data.Options[0].Options + m := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(opts)) + for _, opt := range opts { + m[opt.Name] = opt + } + return m +} + diff --git a/internal/db/repos/settingsrepo/repo.go b/internal/db/repos/settingsrepo/repo.go index 198034c..bbd6bd3 100644 --- a/internal/db/repos/settingsrepo/repo.go +++ b/internal/db/repos/settingsrepo/repo.go @@ -19,6 +19,75 @@ func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } +func (r *Repo) SetMeetingLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { + const q = ` + INSERT INTO meeting_settings (guild_id, lobby_channel_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) + DO UPDATE SET lobby_channel_id = EXCLUDED.lobby_channel_id + ` + _, err := r.db.ExecContext(ctx, q, guildID, lobbyChannelID) + return err +} + +func (r *Repo) GetMeetingLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { + const q = ` + SELECT lobby_channel_id + FROM meeting_settings + WHERE guild_id = $1 + ` + row := r.db.QueryRowContext(ctx, q, guildID) + var ch sql.NullInt64 + switch err := row.Scan(&ch); err { + case nil: + if !ch.Valid { + return 0, false, nil + } + return ch.Int64, true, nil + case sql.ErrNoRows: + return 0, false, nil + default: + return 0, false, err + } +} + +func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { + const q = ` + INSERT INTO meeting_temp_channels (guild_id, channel_id, created_by) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, channel_id) DO NOTHING + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID, createdBy) + return err +} + +func (r *Repo) RemoveMeetingTempChannel(ctx context.Context, guildID, channelID int64) error { + const q = ` + DELETE FROM meeting_temp_channels + WHERE guild_id = $1 AND channel_id = $2 + ` + _, err := r.db.ExecContext(ctx, q, guildID, channelID) + return err +} + +func (r *Repo) IsMeetingTempChannel(ctx context.Context, guildID, channelID int64) (bool, error) { + const q = ` + SELECT 1 + FROM meeting_temp_channels + WHERE guild_id = $1 AND channel_id = $2 + ` + row := r.db.QueryRowContext(ctx, q, guildID, channelID) + 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) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error { const q = ` INSERT INTO levelsettings (guild_id, levelsys) diff --git a/internal/db/services/meeting/service.go b/internal/db/services/meeting/service.go new file mode 100644 index 0000000..3109d1f --- /dev/null +++ b/internal/db/services/meeting/service.go @@ -0,0 +1,36 @@ +package meeting + +import ( + "context" + + "velox-bot/internal/db/repos/settingsrepo" +) + +type Service struct { + settings *settingsrepo.Repo +} + +func New(settings *settingsrepo.Repo) *Service { + return &Service{settings: settings} +} + +func (s *Service) SetLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { + return s.settings.SetMeetingLobbyChannel(ctx, guildID, lobbyChannelID) +} + +func (s *Service) GetLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { + return s.settings.GetMeetingLobbyChannel(ctx, guildID) +} + +func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { + return s.settings.AddMeetingTempChannel(ctx, guildID, channelID, createdBy) +} + +func (s *Service) RemoveTempChannel(ctx context.Context, guildID, channelID int64) error { + return s.settings.RemoveMeetingTempChannel(ctx, guildID, channelID) +} + +func (s *Service) IsTempChannel(ctx context.Context, guildID, channelID int64) (bool, error) { + return s.settings.IsMeetingTempChannel(ctx, guildID, channelID) +} + diff --git a/internal/db/services/services.go b/internal/db/services/services.go index f77acaf..103def1 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -3,21 +3,24 @@ package services import ( "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" + "velox-bot/internal/db/services/meeting" "velox-bot/internal/db/services/rps" ) type Services struct { Level *level.Service LevelSettings *levelsettings.Service + Meeting *meeting.Service RPS *rps.Service } var Global *Services -func NewServices(level *level.Service, levelSettings *levelsettings.Service, rps *rps.Service) *Services { +func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, rps *rps.Service) *Services { s := &Services{ Level: level, LevelSettings: levelSettings, + Meeting: meeting, RPS: rps, } Global = s diff --git a/internal/events/meeting_voice.go b/internal/events/meeting_voice.go new file mode 100644 index 0000000..695599c --- /dev/null +++ b/internal/events/meeting_voice.go @@ -0,0 +1,111 @@ +package events + +import ( + "context" + "fmt" + "log" + "strconv" + "time" + + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +// HandleVoiceStateUpdate implements the "meeting lobby" behavior: +// - When a user joins the configured lobby voice channel, create a temporary voice channel +// with the same UserLimit and move the user into it. +// - When a temporary channel becomes empty, delete it. +func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) { + if svc == nil || svc.Meeting == nil { + return + } + if vs == nil || vs.VoiceState == nil { + return + } + if vs.GuildID == "" || vs.UserID == "" { + return + } + + ctx := context.Background() + guildID64, _ := strconv.ParseInt(vs.GuildID, 10, 64) + + // Clean up the channel the user left (if any) + if vs.BeforeUpdate != nil && vs.BeforeUpdate.ChannelID != "" { + tryCleanupTempChannel(s, ctx, svc, vs.GuildID, guildID64, vs.BeforeUpdate.ChannelID) + } + + // If not joining a channel (disconnect), nothing else to do. + if vs.ChannelID == "" { + return + } + + lobbyID, ok, err := svc.Meeting.GetLobbyChannel(ctx, guildID64) + if err != nil || !ok || lobbyID == 0 { + return + } + if vs.ChannelID != strconv.FormatInt(lobbyID, 10) { + return + } + + lobbyCh, err := s.Channel(vs.ChannelID) + if err != nil || lobbyCh == nil { + return + } + + // Create the new channel in the same category as the lobby, with same user limit. + newName := fmt.Sprintf("%s • %s", lobbyCh.Name, time.Now().Format("15:04")) + newCh, err := s.GuildChannelCreateComplex(vs.GuildID, discordgo.GuildChannelCreateData{ + Name: newName, + Type: discordgo.ChannelTypeGuildVoice, + ParentID: lobbyCh.ParentID, + UserLimit: lobbyCh.UserLimit, + Bitrate: lobbyCh.Bitrate, + }) + if err != nil || newCh == nil { + log.Printf("meeting: failed creating temp channel guild=%s user=%s err=%v", vs.GuildID, vs.UserID, err) + return + } + + newChID64, _ := strconv.ParseInt(newCh.ID, 10, 64) + _ = svc.Meeting.AddTempChannel(ctx, guildID64, newChID64, mustParseInt64(vs.UserID)) + + // Move the user into the new channel. + targetID := newCh.ID + if err := s.GuildMemberMove(vs.GuildID, vs.UserID, &targetID); err != nil { + log.Printf("meeting: failed moving member guild=%s user=%s channel=%s err=%v", vs.GuildID, vs.UserID, newCh.ID, err) + // If move fails, delete the channel to avoid junk. + _, _ = s.ChannelDelete(newCh.ID) + _ = svc.Meeting.RemoveTempChannel(ctx, guildID64, newChID64) + return + } +} + +func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *services.Services, guildID string, guildID64 int64, channelID string) { + chID64, err := strconv.ParseInt(channelID, 10, 64) + if err != nil || chID64 == 0 { + return + } + isTemp, err := svc.Meeting.IsTempChannel(ctx, guildID64, chID64) + if err != nil || !isTemp { + return + } + + // Check if anyone is still in the channel. + g, err := s.Guild(guildID) + if err != nil || g == nil { + return + } + for _, st := range g.VoiceStates { + if st != nil && st.ChannelID == channelID { + return + } + } + + if _, err := s.ChannelDelete(channelID); err != nil { + // If already deleted, still remove from DB. + log.Printf("meeting: failed deleting empty temp channel guild=%s channel=%s err=%v", guildID, channelID, err) + } + _ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64) +} + diff --git a/main.go b/main.go index 665a88f..8073d8a 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "velox-bot/internal/db/services" "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" + "velox-bot/internal/db/services/meeting" "velox-bot/internal/db/services/rps" ) @@ -38,8 +39,9 @@ func main() { rpsRepo := rpsrepo.NewRepo(db) levelService := level.New(levelRepo, settingsRepo) levelSettingsService := levelsettings.New(settingsRepo) + meetingService := meeting.New(settingsRepo) rpsService := rps.New(rpsRepo) - services := services.NewServices(levelService, levelSettingsService, rpsService) + services := services.NewServices(levelService, levelSettingsService, meetingService, rpsService) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services) if err != nil { diff --git a/schema.sql b/schema.sql index d7e5ff7..e647669 100644 --- a/schema.sql +++ b/schema.sql @@ -68,4 +68,18 @@ CREATE TABLE IF NOT EXISTS logsettings ( is_enabled BOOLEAN NOT NULL DEFAULT FALSE ); +-- Meeting rooms (voice lobby -> auto-created temporary voice channels) +CREATE TABLE IF NOT EXISTS meeting_settings ( + guild_id BIGINT PRIMARY KEY, + lobby_channel_id BIGINT +); + +CREATE TABLE IF NOT EXISTS meeting_temp_channels ( + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, channel_id) +); + -- \ No newline at end of file