package public import ( "context" "fmt" "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}, }, }, }, { 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, }, }, }, }, } 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) 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.") } } 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()+".") } 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 }