diff --git a/internal/commands/meeting/public/meeting.go b/internal/commands/meeting/public/meeting.go index c34e2bb..8c26065 100644 --- a/internal/commands/meeting/public/meeting.go +++ b/internal/commands/meeting/public/meeting.go @@ -2,6 +2,7 @@ package public import ( "context" + "fmt" "strconv" "velox-bot/internal/commands/meeting/shared" @@ -28,6 +29,47 @@ var Meeting = &discordgo.ApplicationCommand{ }, }, }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "lock", + Description: "Lock your current meeting room (block others from joining)", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "lock-private", + Description: "Lock and hide your meeting room (block joining and visibility)", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "unlock", + Description: "Unlock your current meeting room (allow others to join)", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "invite", + Description: "Allow a user to join your locked meeting room", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to allow", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "uninvite", + Description: "Remove a previously invited user's permission to join", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to remove", + Required: true, + }, + }, + }, }, } @@ -45,6 +87,16 @@ func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { switch data.Options[0].Name { case "set-lobby": handleSetLobby(s, i) + case "lock": + handleLock(s, i) + case "lock-private": + handleLockPrivate(s, i) + case "unlock": + handleUnlock(s, i) + case "invite": + handleInvite(s, i) + case "uninvite": + handleUninvite(s, i) default: shared.RespondEphemeral(s, i, "Unknown subcommand.") } @@ -83,3 +135,198 @@ func handleSetLobby(s *discordgo.Session, i *discordgo.InteractionCreate) { shared.RespondEphemeral(s, i, "Meeting lobby set to "+ch.Mention()+".") } +func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, chID64, guildID64, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + // Deny CONNECT for @everyone (guild ID). + if err := s.ChannelPermissionSet( + ch.ID, + i.GuildID, + discordgo.PermissionOverwriteTypeRole, + 0, + discordgo.PermissionVoiceConnect, + ); err != nil { + shared.RespondEphemeral(s, i, "Failed to lock the meeting room.") + return + } + + shared.RespondEphemeral(s, i, "Locked "+ch.Mention()+". Use `/meeting invite user:@someone` to allow specific people.") + + _ = chID64 + _ = guildID64 +} + +func handleLockPrivate(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + // Deny CONNECT and VIEW_CHANNEL for @everyone (guild ID). + deny := int64(discordgo.PermissionVoiceConnect | discordgo.PermissionViewChannel) + if err := s.ChannelPermissionSet( + ch.ID, + i.GuildID, + discordgo.PermissionOverwriteTypeRole, + 0, + deny, + ); err != nil { + shared.RespondEphemeral(s, i, "Failed to lock the meeting room.") + return + } + + shared.RespondEphemeral(s, i, "Locked (private) "+ch.Mention()+". Use `/meeting invite user:@someone` to allow specific people.") +} + +func handleUnlock(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + // Remove the @everyone overwrite so defaults apply again. + if err := s.ChannelPermissionDelete(ch.ID, i.GuildID); err != nil { + shared.RespondEphemeral(s, i, "Failed to unlock the meeting room.") + return + } + + shared.RespondEphemeral(s, i, "Unlocked "+ch.Mention()+".") +} + +func handleInvite(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + userOpt, ok := optMap["user"] + if !ok { + shared.RespondEphemeral(s, i, "Missing user option.") + return + } + u := userOpt.UserValue(s) + if u == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + // Allow CONNECT for the invited user. + if err := s.ChannelPermissionSet( + ch.ID, + u.ID, + discordgo.PermissionOverwriteTypeMember, + discordgo.PermissionVoiceConnect, + 0, + ); err != nil { + shared.RespondEphemeral(s, i, "Failed to invite user.") + return + } + + shared.RespondEphemeral(s, i, "Invited "+u.Mention()+" to "+ch.Mention()+".") +} + +func handleUninvite(s *discordgo.Session, i *discordgo.InteractionCreate) { + ch, _, _, ok := requireOwnTempVoiceChannel(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + userOpt, ok := optMap["user"] + if !ok { + shared.RespondEphemeral(s, i, "Missing user option.") + return + } + u := userOpt.UserValue(s) + if u == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + // Remove any member-specific overwrite for this user. + if err := s.ChannelPermissionDelete(ch.ID, u.ID); err != nil { + shared.RespondEphemeral(s, i, "Failed to remove invite.") + return + } + + shared.RespondEphemeral(s, i, "Removed "+u.Mention()+" from "+ch.Mention()+".") +} + +func requireOwnTempVoiceChannel(s *discordgo.Session, i *discordgo.InteractionCreate) (channel *discordgo.Channel, channelID64 int64, guildID64 int64, ok bool) { + guildID64, ok = shared.ParseGuildID(s, i) + if !ok { + return nil, 0, 0, false + } + if i.Member == nil || i.Member.User == nil { + shared.RespondEphemeral(s, i, "Missing member info.") + return nil, 0, 0, false + } + + voiceChannelID, found := findMemberVoiceChannelID(s, i.GuildID, i.Member.User.ID) + if !found || voiceChannelID == "" { + shared.RespondEphemeral(s, i, "You must be in your meeting voice channel to use this.") + return nil, 0, 0, false + } + + ch, err := s.Channel(voiceChannelID) + if err != nil || ch == nil { + shared.RespondEphemeral(s, i, "Failed to load your voice channel.") + return nil, 0, 0, false + } + if ch.Type != discordgo.ChannelTypeGuildVoice { + shared.RespondEphemeral(s, i, "This only works in a voice channel.") + return nil, 0, 0, false + } + + channelID64, err = strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid channel ID.") + return nil, 0, 0, false + } + + isTemp, err := services.Global.Meeting.IsTempChannel(context.Background(), guildID64, channelID64) + if err != nil || !isTemp { + shared.RespondEphemeral(s, i, "This is not a bot-created meeting room.") + return nil, 0, 0, false + } + + ownerID, ownerOK, err := services.Global.Meeting.GetTempChannelOwner(context.Background(), guildID64, channelID64) + if err != nil || !ownerOK { + shared.RespondEphemeral(s, i, "Failed to verify meeting room owner.") + return nil, 0, 0, false + } + + invokerID64, _ := strconv.ParseInt(i.Member.User.ID, 10, 64) + if invokerID64 == 0 || ownerID != invokerID64 { + shared.RespondEphemeral(s, i, fmt.Sprintf("Only the room creator can do this. (creator: <@%d>)", ownerID)) + return nil, 0, 0, false + } + + return ch, channelID64, guildID64, true +} + +func findMemberVoiceChannelID(s *discordgo.Session, guildID, userID string) (string, bool) { + // Prefer session state cache (discordgo tracks voice states there when IntentsGuildVoiceStates is enabled). + if s != nil && s.State != nil { + if vs, err := s.State.VoiceState(guildID, userID); err == nil && vs != nil { + if vs.ChannelID != "" { + return vs.ChannelID, true + } + return "", false + } + // Fallback to cached guild object (State, not REST). + if g, err := s.State.Guild(guildID); err == nil && g != nil { + for _, st := range g.VoiceStates { + if st != nil && st.UserID == userID { + return st.ChannelID, st.ChannelID != "" + } + } + } + } + return "", false +} + diff --git a/internal/db/repos/settingsrepo/repo.go b/internal/db/repos/settingsrepo/repo.go index bbd6bd3..82035ba 100644 --- a/internal/db/repos/settingsrepo/repo.go +++ b/internal/db/repos/settingsrepo/repo.go @@ -88,6 +88,27 @@ func (r *Repo) IsMeetingTempChannel(ctx context.Context, guildID, channelID int6 } } +func (r *Repo) GetMeetingTempChannelOwner(ctx context.Context, guildID, channelID int64) (createdBy int64, ok bool, err error) { + const q = ` + SELECT created_by + FROM meeting_temp_channels + WHERE guild_id = $1 AND channel_id = $2 + ` + row := r.db.QueryRowContext(ctx, q, guildID, channelID) + var cb sql.NullInt64 + switch err := row.Scan(&cb); err { + case nil: + if !cb.Valid { + return 0, false, nil + } + return cb.Int64, true, nil + case sql.ErrNoRows: + return 0, false, nil + default: + return 0, 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 index 3109d1f..7c2b585 100644 --- a/internal/db/services/meeting/service.go +++ b/internal/db/services/meeting/service.go @@ -34,3 +34,7 @@ func (s *Service) IsTempChannel(ctx context.Context, guildID, channelID int64) ( return s.settings.IsMeetingTempChannel(ctx, guildID, channelID) } +func (s *Service) GetTempChannelOwner(ctx context.Context, guildID, channelID int64) (createdBy int64, ok bool, err error) { + return s.settings.GetMeetingTempChannelOwner(ctx, guildID, channelID) +} + diff --git a/internal/events/meeting_voice.go b/internal/events/meeting_voice.go index 695599c..d63314a 100644 --- a/internal/events/meeting_voice.go +++ b/internal/events/meeting_voice.go @@ -92,7 +92,12 @@ func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *servi } // Check if anyone is still in the channel. - g, err := s.Guild(guildID) + // IMPORTANT: don't use REST `s.Guild(...)` here; it doesn't include VoiceStates. + // Use the session state cache (requires IntentsGuildVoiceStates). + if s == nil || s.State == nil { + return + } + g, err := s.State.Guild(guildID) if err != nil || g == nil { return }