feat: meeting rooms
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()+".")
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user