Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10c2efef7d | ||
|
|
e33eea6d24 | ||
|
|
76aeca3de6 | ||
|
|
c4dd7302bc |
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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"
|
||||||
|
"velox-bot/internal/commands/projects"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
@@ -21,6 +23,8 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
|||||||
cmdlevel.Rank,
|
cmdlevel.Rank,
|
||||||
cmdlevel.Leaderboard,
|
cmdlevel.Leaderboard,
|
||||||
cmdlevel.Rewards,
|
cmdlevel.Rewards,
|
||||||
|
meeting.Meeting,
|
||||||
|
projects.Projects,
|
||||||
}
|
}
|
||||||
|
|
||||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||||
@@ -36,6 +40,8 @@ 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,
|
||||||
|
"projects": projects.ProjectsHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"velox-bot/internal/commands/projects/shared"
|
||||||
|
"velox-bot/internal/db/services"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Projects = &discordgo.ApplicationCommand{
|
||||||
|
Name: "projects",
|
||||||
|
Description: "Manage and list ongoing projects",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "list",
|
||||||
|
Description: "List active projects with creators and helpers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "create",
|
||||||
|
Description: "Create a new active project",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Name: "name",
|
||||||
|
Description: "Project name",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Name: "description",
|
||||||
|
Description: "Short description (optional)",
|
||||||
|
Required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "add-helper",
|
||||||
|
Description: "Add a helper to an existing project",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionInteger,
|
||||||
|
Name: "project-id",
|
||||||
|
Description: "ID of the project",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionUser,
|
||||||
|
Name: "user",
|
||||||
|
Description: "User to add as helper",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !shared.RequireGuild(s, i) || !shared.RequireProjectsService(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
handleList(s, i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch data.Options[0].Name {
|
||||||
|
case "list":
|
||||||
|
handleList(s, i)
|
||||||
|
case "create":
|
||||||
|
handleCreate(s, i)
|
||||||
|
case "add-helper":
|
||||||
|
handleAddHelper(s, i)
|
||||||
|
default:
|
||||||
|
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
guildID, ok := shared.ParseGuildID(s, i)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = 25
|
||||||
|
items, err := services.Global.Projects.ListActiveWithMembers(context.Background(), guildID, limit)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to load projects.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
shared.RespondEphemeral(s, i, "There are no active projects at the moment.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: "Active projects",
|
||||||
|
Description: "",
|
||||||
|
Color: 0x00bcd4,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil || item.Project == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := item.Project
|
||||||
|
name := p.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "Unnamed project"
|
||||||
|
}
|
||||||
|
creatorMention := mentionUser(item.Creator)
|
||||||
|
|
||||||
|
var helpersMentions []string
|
||||||
|
for _, h := range item.Helpers {
|
||||||
|
helpersMentions = append(helpersMentions, mentionUser(h))
|
||||||
|
}
|
||||||
|
helpersText := "None"
|
||||||
|
if len(helpersMentions) > 0 {
|
||||||
|
helpersText = strings.Join(helpersMentions, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := "**Creator:** " + creatorMention + "\n" +
|
||||||
|
"**Helpers:** " + helpersText
|
||||||
|
if p.Description != "" {
|
||||||
|
desc += "\n" + p.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||||
|
Name: name,
|
||||||
|
Value: desc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{embed},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mentionUser(id int64) string {
|
||||||
|
if id == 0 {
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
return "<@" + strconv.FormatInt(id, 10) + ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !shared.RequireManageGuild(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID, ok := shared.ParseGuildID(s, i)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.Member == nil || i.Member.User == nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
shared.RespondEphemeral(s, i, "Missing options.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opt := data.Options[0]
|
||||||
|
|
||||||
|
var name, description string
|
||||||
|
for _, o := range opt.Options {
|
||||||
|
switch o.Name {
|
||||||
|
case "name":
|
||||||
|
name = o.StringValue()
|
||||||
|
case "description":
|
||||||
|
description = o.StringValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
shared.RespondEphemeral(s, i, "Project name cannot be empty.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to create project.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !shared.RequireManageGuild(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guildID, ok := shared.ParseGuildID(s, i)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
shared.RespondEphemeral(s, i, "Missing options.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opt := data.Options[0]
|
||||||
|
|
||||||
|
var projectID int64
|
||||||
|
var userID string
|
||||||
|
for _, o := range opt.Options {
|
||||||
|
switch o.Name {
|
||||||
|
case "project-id":
|
||||||
|
projectID = o.IntValue()
|
||||||
|
case "user":
|
||||||
|
if u := o.UserValue(s); u != nil {
|
||||||
|
userID = u.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectID <= 0 {
|
||||||
|
shared.RespondEphemeral(s, i, "Invalid project ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := services.Global.Projects.ProjectExistsInGuild(context.Background(), guildID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to load project.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
shared.RespondEphemeral(s, i, "Project `"+strconv.FormatInt(projectID, 10)+"` not found in this server.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID64, err := strconv.ParseInt(userID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.")
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"velox-bot/internal/commands/projects/public"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Projects *discordgo.ApplicationCommand = public.Projects
|
||||||
|
)
|
||||||
|
|
||||||
|
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) }
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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 RequireProjectsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||||
|
if services.Global == nil || services.Global.Projects == nil {
|
||||||
|
RespondEphemeral(s, i, "Projects service is not available.")
|
||||||
|
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 projects.")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package projectsrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ID int64
|
||||||
|
GuildID int64
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
IsActive bool
|
||||||
|
CreatedBy int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectWithMembers struct {
|
||||||
|
Project *Project
|
||||||
|
Creator int64
|
||||||
|
Helpers []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepo(db *sql.DB) *Repo {
|
||||||
|
return &Repo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO projects (guild_id, name, description, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
var id int64
|
||||||
|
if err := r.db.QueryRowContext(ctx, q, guildID, name, description, creatorID).Scan(&id); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const qMember = `
|
||||||
|
INSERT INTO project_members (project_id, user_id, is_creator)
|
||||||
|
VALUES ($1, $2, TRUE)
|
||||||
|
ON CONFLICT (project_id, user_id) DO NOTHING
|
||||||
|
`
|
||||||
|
if _, err := r.db.ExecContext(ctx, qMember, id, creatorID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) AddHelper(ctx context.Context, projectID, userID int64) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO project_members (project_id, user_id, is_creator)
|
||||||
|
VALUES ($1, $2, FALSE)
|
||||||
|
ON CONFLICT (project_id, user_id) DO NOTHING
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, projectID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT 1
|
||||||
|
FROM projects
|
||||||
|
WHERE guild_id = $1 AND id = $2
|
||||||
|
`
|
||||||
|
row := r.db.QueryRowContext(ctx, q, guildID, projectID)
|
||||||
|
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) ListActiveProjectsWithMembers(ctx context.Context, guildID int64, limit int) ([]*ProjectWithMembers, error) {
|
||||||
|
const qProjects = `
|
||||||
|
SELECT id, name, description, created_by
|
||||||
|
FROM projects
|
||||||
|
WHERE guild_id = $1 AND is_active = TRUE
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
rows, err := r.db.QueryContext(ctx, qProjects, guildID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []*ProjectWithMembers
|
||||||
|
for rows.Next() {
|
||||||
|
p := &Project{GuildID: guildID, IsActive: true}
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, &ProjectWithMembers{
|
||||||
|
Project: p,
|
||||||
|
Creator: p.CreatedBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load members per project.
|
||||||
|
const qMembers = `
|
||||||
|
SELECT project_id, user_id, is_creator
|
||||||
|
FROM project_members
|
||||||
|
WHERE project_id = ANY($1)
|
||||||
|
`
|
||||||
|
ids := make([]int64, len(out))
|
||||||
|
for i, p := range out {
|
||||||
|
ids[i] = p.Project.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsM, err := r.db.QueryContext(ctx, qMembers, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rowsM.Close()
|
||||||
|
|
||||||
|
index := make(map[int64]*ProjectWithMembers, len(out))
|
||||||
|
for _, p := range out {
|
||||||
|
index[p.Project.ID] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
for rowsM.Next() {
|
||||||
|
var pid, uid int64
|
||||||
|
var isCreator bool
|
||||||
|
if err := rowsM.Scan(&pid, &uid, &isCreator); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item, ok := index[pid]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isCreator {
|
||||||
|
item.Creator = uid
|
||||||
|
} else {
|
||||||
|
item.Helpers = append(item.Helpers, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rowsM.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -19,6 +19,96 @@ 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) 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)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetTempChannelOwner(ctx context.Context, guildID, channelID int64) (createdBy int64, ok bool, err error) {
|
||||||
|
return s.settings.GetMeetingTempChannelOwner(ctx, guildID, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/projectsrepo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *projectsrepo.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repo *projectsrepo.Repo) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) {
|
||||||
|
return s.repo.CreateProject(ctx, guildID, creatorID, name, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AddHelper(ctx context.Context, projectID, userID int64) error {
|
||||||
|
return s.repo.AddHelper(ctx, projectID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListActiveWithMembers(ctx context.Context, guildID int64, limit int) ([]*projectsrepo.ProjectWithMembers, error) {
|
||||||
|
return s.repo.ListActiveProjectsWithMembers(ctx, guildID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
|
||||||
|
return s.repo.ProjectExistsInGuild(ctx, guildID, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,21 +3,27 @@ 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/projects"
|
||||||
"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
|
||||||
|
Projects *projects.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, projects *projects.Service, rps *rps.Service) *Services {
|
||||||
s := &Services{
|
s := &Services{
|
||||||
Level: level,
|
Level: level,
|
||||||
LevelSettings: levelSettings,
|
LevelSettings: levelSettings,
|
||||||
|
Meeting: meeting,
|
||||||
|
Projects: projects,
|
||||||
RPS: rps,
|
RPS: rps,
|
||||||
}
|
}
|
||||||
Global = s
|
Global = s
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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.
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,11 +11,14 @@ import (
|
|||||||
"velox-bot/internal/config"
|
"velox-bot/internal/config"
|
||||||
"velox-bot/internal/db"
|
"velox-bot/internal/db"
|
||||||
"velox-bot/internal/db/repos/levelrepo"
|
"velox-bot/internal/db/repos/levelrepo"
|
||||||
|
"velox-bot/internal/db/repos/projectsrepo"
|
||||||
"velox-bot/internal/db/repos/rpsrepo"
|
"velox-bot/internal/db/repos/rpsrepo"
|
||||||
"velox-bot/internal/db/repos/settingsrepo"
|
"velox-bot/internal/db/repos/settingsrepo"
|
||||||
"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/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,10 +39,13 @@ func main() {
|
|||||||
levelRepo := levelrepo.NewRepo(db)
|
levelRepo := levelrepo.NewRepo(db)
|
||||||
settingsRepo := settingsrepo.NewRepo(db)
|
settingsRepo := settingsrepo.NewRepo(db)
|
||||||
rpsRepo := rpsrepo.NewRepo(db)
|
rpsRepo := rpsrepo.NewRepo(db)
|
||||||
|
projectsRepo := projectsrepo.NewRepo(db)
|
||||||
levelService := level.New(levelRepo, settingsRepo)
|
levelService := level.New(levelRepo, settingsRepo)
|
||||||
levelSettingsService := levelsettings.New(settingsRepo)
|
levelSettingsService := levelsettings.New(settingsRepo)
|
||||||
|
meetingService := meeting.New(settingsRepo)
|
||||||
|
projectsService := projects.New(projectsRepo)
|
||||||
rpsService := rps.New(rpsRepo)
|
rpsService := rps.New(rpsRepo)
|
||||||
services := services.NewServices(levelService, levelSettingsService, rpsService)
|
services := services.NewServices(levelService, levelSettingsService, meetingService, projectsService, 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 {
|
||||||
|
|||||||
+32
@@ -68,4 +68,36 @@ 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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Projects & members
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_members (
|
||||||
|
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
is_creator BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
PRIMARY KEY (project_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
--
|
--
|
||||||
Reference in New Issue
Block a user