feat: meeting room locking
This commit is contained in:
@@ -2,6 +2,7 @@ package public
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"velox-bot/internal/commands/meeting/shared"
|
"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 {
|
switch data.Options[0].Name {
|
||||||
case "set-lobby":
|
case "set-lobby":
|
||||||
handleSetLobby(s, i)
|
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:
|
default:
|
||||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
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()+".")
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
func (r *Repo) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO levelsettings (guild_id, levelsys)
|
INSERT INTO levelsettings (guild_id, levelsys)
|
||||||
|
|||||||
@@ -34,3 +34,7 @@ func (s *Service) IsTempChannel(ctx context.Context, guildID, channelID int64) (
|
|||||||
return s.settings.IsMeetingTempChannel(ctx, guildID, channelID)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,12 @@ func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *servi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if anyone is still in the channel.
|
// 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 {
|
if err != nil || g == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user