feat: meeting rooms

This commit is contained in:
2026-03-17 16:59:26 +00:00
parent 0b36bc9e93
commit c4dd7302bc
11 changed files with 415 additions and 2 deletions
+10
View File
@@ -24,6 +24,12 @@ func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand,
return nil, err 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{ return &Bot{
Session: session, Session: session,
AppID: appID, AppID: appID,
@@ -54,6 +60,10 @@ func (b *Bot) Start() error {
events.HandleMessageCreate(s, m, b.Services) events.HandleMessageCreate(s, m, b.Services)
}) })
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
events.HandleVoiceStateUpdate(s, vs, b.Services)
})
return nil return nil
} }
+3
View File
@@ -4,6 +4,7 @@ import (
"velox-bot/internal/commands/fun" "velox-bot/internal/commands/fun"
"velox-bot/internal/commands/help" "velox-bot/internal/commands/help"
cmdlevel "velox-bot/internal/commands/level" cmdlevel "velox-bot/internal/commands/level"
"velox-bot/internal/commands/meeting"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -21,6 +22,7 @@ var AllCommands = []*discordgo.ApplicationCommand{
cmdlevel.Rank, cmdlevel.Rank,
cmdlevel.Leaderboard, cmdlevel.Leaderboard,
cmdlevel.Rewards, cmdlevel.Rewards,
meeting.Meeting,
} }
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ 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, "rank": cmdlevel.RankHandler,
"leaderboard": cmdlevel.LeaderboardHandler, "leaderboard": cmdlevel.LeaderboardHandler,
"rewards": cmdlevel.RewardsHandler, "rewards": cmdlevel.RewardsHandler,
"meeting": meeting.MeetingHandler,
} }
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { 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()+".")
}
+14
View File
@@ -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
}
+69
View File
@@ -19,6 +19,75 @@ func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db} 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 { 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)
+36
View File
@@ -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)
}
+4 -1
View File
@@ -3,21 +3,24 @@ package services
import ( import (
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/rps" "velox-bot/internal/db/services/rps"
) )
type Services struct { type Services struct {
Level *level.Service Level *level.Service
LevelSettings *levelsettings.Service LevelSettings *levelsettings.Service
Meeting *meeting.Service
RPS *rps.Service RPS *rps.Service
} }
var Global *Services 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{ s := &Services{
Level: level, Level: level,
LevelSettings: levelSettings, LevelSettings: levelSettings,
Meeting: meeting,
RPS: rps, RPS: rps,
} }
Global = s Global = s
+111
View File
@@ -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)
}
+3 -1
View File
@@ -16,6 +16,7 @@ import (
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/rps" "velox-bot/internal/db/services/rps"
) )
@@ -38,8 +39,9 @@ func main() {
rpsRepo := rpsrepo.NewRepo(db) rpsRepo := rpsrepo.NewRepo(db)
levelService := level.New(levelRepo, settingsRepo) levelService := level.New(levelRepo, settingsRepo)
levelSettingsService := levelsettings.New(settingsRepo) levelSettingsService := levelsettings.New(settingsRepo)
meetingService := meeting.New(settingsRepo)
rpsService := rps.New(rpsRepo) 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) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services)
if err != nil { if err != nil {
+14
View File
@@ -68,4 +68,18 @@ CREATE TABLE IF NOT EXISTS logsettings (
is_enabled BOOLEAN NOT NULL DEFAULT FALSE 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)
);
-- --