7 Commits
28 changed files with 2506 additions and 3 deletions
+22
View File
@@ -24,6 +24,18 @@ 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
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
session.Identify.Intents = discordgo.IntentsGuilds |
discordgo.IntentsGuildMessages |
discordgo.IntentsGuildVoiceStates |
discordgo.IntentsGuildMessageReactions |
discordgo.IntentsDirectMessages |
discordgo.IntentsDirectMessageReactions
return &Bot{
Session: session,
AppID: appID,
@@ -54,6 +66,16 @@ 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)
})
b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) {
events.HandleMessageReactionAdd(s, r, b.Services)
})
events.StartScheduleReminderLoop(b.Session, b.Services)
return nil
}
+12
View File
@@ -4,6 +4,10 @@ import (
"velox-bot/internal/commands/fun"
"velox-bot/internal/commands/help"
cmdlevel "velox-bot/internal/commands/level"
"velox-bot/internal/commands/meeting"
"velox-bot/internal/commands/timezone"
"velox-bot/internal/commands/schedule"
"velox-bot/internal/commands/projects"
"github.com/bwmarrin/discordgo"
)
@@ -21,6 +25,10 @@ var AllCommands = []*discordgo.ApplicationCommand{
cmdlevel.Rank,
cmdlevel.Leaderboard,
cmdlevel.Rewards,
meeting.Meeting,
timezone.Timezone,
schedule.Schedule,
projects.Projects,
}
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
@@ -36,6 +44,10 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
"rank": cmdlevel.RankHandler,
"leaderboard": cmdlevel.LeaderboardHandler,
"rewards": cmdlevel.RewardsHandler,
"meeting": meeting.MeetingHandler,
"timezone": timezone.TimezoneHandler,
"schedule": schedule.ScheduleHandler,
"projects": projects.ProjectsHandler,
}
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
+332
View File
@@ -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
}
+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
}
@@ -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)+"`.")
}
+14
View File
@@ -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,439 @@
package public
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"velox-bot/internal/commands/schedule/shared"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
var Schedule = &discordgo.ApplicationCommand{
Name: "schedule",
Description: "Schedule 1:1 sessions",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "create",
Description: "Create a new session with a user",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to invite",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "datetime",
Description: "When (UTC), format: 2006-01-02 15:04",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "description",
Description: "What is this session about? (optional)",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "list",
Description: "List your upcoming sessions",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "reschedule",
Description: "Reschedule an existing session",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "id",
Description: "ID of the session (from /schedule list)",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "datetime",
Description: "New datetime (UTC), format: 2006-01-02 15:04",
Required: true,
},
},
},
},
}
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireGuild(s, i) || !shared.RequireScheduleService(s, i) {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
handleList(s, i)
return
}
switch data.Options[0].Name {
case "create":
handleCreate(s, i)
case "list":
handleList(s, i)
case "reschedule":
handleReschedule(s, i)
default:
shared.RespondEphemeral(s, i, "Unknown subcommand.")
}
}
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
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 (
targetUser *discordgo.User
whenStr string
desc string
)
for _, o := range opt.Options {
switch o.Name {
case "user":
targetUser = o.UserValue(s)
case "datetime":
whenStr = o.StringValue()
case "description":
desc = o.StringValue()
}
}
if targetUser == nil {
shared.RespondEphemeral(s, i, "Invalid user.")
return
}
if targetUser.ID == i.Member.User.ID {
shared.RespondEphemeral(s, i, "You cannot schedule a session with yourself.")
return
}
requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid user ID.")
return
}
inviteeID, err := strconv.ParseInt(targetUser.ID, 10, 64)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid target user ID.")
return
}
whenStr = strings.TrimSpace(whenStr)
if whenStr == "" {
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
return
}
// Resolve requester's timezone and parse input using explicit layout in that zone.
loc, zone, _, err := services.Global.UserSettings.GetTimezone(context.Background(), requesterID)
if err != nil {
loc = time.UTC
zone = "UTC"
}
// Expect layout "2006-01-02 15:04" in the user's timezone.
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
return
}
when = when.In(time.UTC)
ctx := context.Background()
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to create schedule.")
return
}
// Send DM to invitee.
dmCh, err := s.UserChannelCreate(targetUser.ID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
return
}
embed := &discordgo.MessageEmbed{
Title: "New session request",
Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID),
Color: 0x4caf50,
}
// Show both UTC and invitee-local time if available.
utcStr := when.Format("2006-01-02 15:04")
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), inviteeID)
if err != nil {
locInv = time.UTC
zoneInv = "UTC"
}
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{
Name: "When (UTC)",
Value: utcStr,
},
{
Name: "When (" + zoneInv + ")",
Value: localInvStr,
},
}
if desc != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "Description",
Value: desc,
})
}
msg, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request rescheduling.",
Embed: embed,
})
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the invited user.")
return
}
// Add reactions for interaction.
for _, emoji := range []string{"✅", "❌", "🔁"} {
_ = s.MessageReactionAdd(dmCh.ID, msg.ID, emoji)
}
// Store linkage between message and schedule.
msgID64, _ := strconv.ParseInt(msg.ID, 10, 64)
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
_ = services.Global.Schedule.AddMessage(ctx, scheduleID, msgID64, chID64, true)
shared.RespondEphemeral(s, i, "Session request created and sent to "+targetUser.Mention()+".")
}
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
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
}
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid user ID.")
return
}
const limit = 10
items, err := services.Global.Schedule.ListForUser(context.Background(), guildID, userID, limit)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load your sessions.")
return
}
if len(items) == 0 {
shared.RespondEphemeral(s, i, "You have no upcoming sessions.")
return
}
var b strings.Builder
for _, sch := range items {
role := "Requester"
otherID := sch.InviteeID
if sch.InviteeID == userID {
role = "Invitee"
otherID = sch.RequesterID
}
status := string(sch.Status)
whenStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
line := fmt.Sprintf("ID `%d` • %s with <@%d> at %s UTC (%s)\n", sch.ID, role, otherID, whenStr, status)
if sch.Description != "" {
line += " " + sch.Description + "\n"
}
b.WriteString(line)
}
shared.RespondEphemeral(s, i, b.String())
}
func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
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 (
idVal int64
whenStr string
)
for _, o := range opt.Options {
switch o.Name {
case "id":
idVal = o.IntValue()
case "datetime":
whenStr = o.StringValue()
}
}
if idVal <= 0 {
shared.RespondEphemeral(s, i, "Invalid session ID.")
return
}
whenStr = strings.TrimSpace(whenStr)
if whenStr == "" {
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
return
}
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid user ID.")
return
}
ctx := context.Background()
sch, err := services.Global.Schedule.GetByID(ctx, idVal)
if err != nil || sch == nil {
shared.RespondEphemeral(s, i, "Session not found.")
return
}
if sch.GuildID != guildID {
shared.RespondEphemeral(s, i, "This session belongs to another server.")
return
}
if userID != sch.RequesterID && userID != sch.InviteeID {
shared.RespondEphemeral(s, i, "You are not part of this session.")
return
}
// Use rescheduler's timezone for parsing.
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
if err != nil {
loc = time.UTC
zone = "UTC"
}
// Expect explicit layout "2006-01-02 15:04" in user's timezone.
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
return
}
when = when.In(time.UTC)
if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil {
shared.RespondEphemeral(s, i, "Failed to reschedule session.")
return
}
// Notify participants via DM and send new request DM to invitee.
whenFmt := when.Format("2006-01-02 15:04")
otherID := sch.InviteeID
if userID == sch.InviteeID {
otherID = sch.RequesterID
}
// DM both about the change, including their local times if available.
for _, uid := range []int64{sch.RequesterID, sch.InviteeID} {
locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid)
if err != nil {
locU = time.UTC
zoneU = "UTC"
}
localU := when.In(locU).Format("2006-01-02 15:04")
msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID)
if sch.Description != "" {
msg += "\nTopic: " + sch.Description
}
ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10))
if err != nil {
continue
}
_, _ = s.ChannelMessageSend(ch.ID, msg)
}
// Send new "request" DM to invitee with reactions again.
inviteeStr := strconv.FormatInt(sch.InviteeID, 10)
inviteeUser, err := s.User(inviteeStr)
if err == nil && inviteeUser != nil {
dmCh, err := s.UserChannelCreate(inviteeStr)
if err == nil {
embed := &discordgo.MessageEmbed{
Title: "Rescheduled session",
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID),
Color: 0xffc107,
}
// Show both UTC and invitee-local time if available.
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID)
if errTZ != nil {
locInv = time.UTC
zoneInv = "UTC"
}
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{
Name: "When (UTC)",
Value: whenFmt,
},
{
Name: "When (" + zoneInv + ")",
Value: localInvStr,
},
}
if sch.Description != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "Description",
Value: sch.Description,
})
}
msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.",
Embed: embed,
})
if err == nil && msgObj != nil {
for _, emoji := range []string{"✅", "❌", "🔁"} {
_ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji)
}
msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64)
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
_ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true)
}
}
}
shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.")
}
+14
View File
@@ -0,0 +1,14 @@
package schedule
import (
"velox-bot/internal/commands/schedule/public"
"github.com/bwmarrin/discordgo"
)
var (
Schedule *discordgo.ApplicationCommand = public.Schedule
)
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) }
@@ -0,0 +1,45 @@
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 RequireScheduleService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if services.Global == nil || services.Global.Schedule == nil {
RespondEphemeral(s, i, "Scheduling 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
}
@@ -0,0 +1,115 @@
package public
import (
"context"
"time"
"velox-bot/internal/commands/timezone/shared"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
var Timezone = &discordgo.ApplicationCommand{
Name: "timezone",
Description: "Configure and view your timezone",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "set",
Description: "Set your timezone (IANA name, e.g., Europe/Lisbon)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "zone",
Description: "Timezone (e.g., Europe/Lisbon, America/New_York)",
Required: true,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "show",
Description: "Show your current timezone",
},
},
}
func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireUserSettingsService(s, i) {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
handleShow(s, i)
return
}
switch data.Options[0].Name {
case "set":
handleSet(s, i)
case "show":
handleShow(s, i)
default:
shared.RespondEphemeral(s, i, "Unknown subcommand.")
}
}
func handleSet(s *discordgo.Session, i *discordgo.InteractionCreate) {
userID, ok := shared.CurrentUserID(i)
if !ok {
shared.RespondEphemeral(s, i, "Could not determine your user ID.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
shared.RespondEphemeral(s, i, "Missing options.")
return
}
opt := data.Options[0]
var zone string
for _, o := range opt.Options {
if o.Name == "zone" {
zone = o.StringValue()
}
}
if zone == "" {
shared.RespondEphemeral(s, i, "Timezone cannot be empty.")
return
}
// Validate via service (which uses time.LoadLocation).
if err := services.Global.UserSettings.SetTimezone(context.Background(), userID, zone); err != nil {
shared.RespondEphemeral(s, i, "Invalid timezone. Use an IANA name like `Europe/Lisbon`.")
return
}
loc, z, _, _ := services.Global.UserSettings.GetTimezone(context.Background(), userID)
now := time.Now().In(loc).Format("2006-01-02 15:04")
shared.RespondEphemeral(s, i, "Your timezone is now set to `"+z+"` (current local time: "+now+").")
}
func handleShow(s *discordgo.Session, i *discordgo.InteractionCreate) {
userID, ok := shared.CurrentUserID(i)
if !ok {
shared.RespondEphemeral(s, i, "Could not determine your user ID.")
return
}
loc, z, userDefined, err := services.Global.UserSettings.GetTimezone(context.Background(), userID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load your timezone.")
return
}
now := time.Now().In(loc).Format("2006-01-02 15:04")
if userDefined {
shared.RespondEphemeral(s, i, "Your timezone is `"+z+"` (current local time: "+now+").")
} else {
shared.RespondEphemeral(s, i, "You don't have a timezone set. Using `UTC` (current time: "+now+"). Use `/timezone set` to configure one.")
}
}
+14
View File
@@ -0,0 +1,14 @@
package timezone
import (
"velox-bot/internal/commands/timezone/public"
"github.com/bwmarrin/discordgo"
)
var (
Timezone *discordgo.ApplicationCommand = public.Timezone
)
func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TimezoneHandler(s, i) }
@@ -0,0 +1,45 @@
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 RequireUserSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if services.Global == nil || services.Global.UserSettings == nil {
RespondEphemeral(s, i, "User settings service is not available.")
return false
}
return true
}
func CurrentUserID(i *discordgo.InteractionCreate) (int64, bool) {
var idStr string
if i.Member != nil && i.Member.User != nil {
idStr = i.Member.User.ID
} else if i.User != nil {
idStr = i.User.ID
}
if idStr == "" {
return 0, false
}
uid, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return 0, false
}
return uid, true
}
+158
View File
@@ -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
}
+189
View File
@@ -0,0 +1,189 @@
package schedulerepo
import (
"context"
"database/sql"
"time"
)
type Repo struct {
db *sql.DB
}
type ScheduleStatus string
const (
StatusPending ScheduleStatus = "pending"
StatusAccepted ScheduleStatus = "accepted"
StatusDeclined ScheduleStatus = "declined"
StatusRescheduleRequested ScheduleStatus = "reschedule_requested"
)
type Schedule struct {
ID int64
GuildID int64
RequesterID int64
InviteeID int64
ScheduledAt time.Time
Status ScheduleStatus
Description string
ReminderSent bool
CreatedAt time.Time
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) {
const q = `
INSERT INTO schedules (guild_id, requester_id, invitee_id, scheduled_at, description)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`
var id int64
if err := r.db.QueryRowContext(ctx, q, guildID, requesterID, inviteeID, when, description).Scan(&id); err != nil {
return 0, err
}
return id, nil
}
func (r *Repo) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
const q = `
INSERT INTO schedule_messages (schedule_id, message_id, channel_id, is_dm)
VALUES ($1, $2, $3, $4)
ON CONFLICT (schedule_id, message_id) DO NOTHING
`
_, err := r.db.ExecContext(ctx, q, scheduleID, messageID, channelID, isDM)
return err
}
func (r *Repo) GetByMessageID(ctx context.Context, messageID int64) (*Schedule, error) {
const q = `
SELECT s.id, s.guild_id, s.requester_id, s.invitee_id, s.scheduled_at, s.status, s.description, s.reminder_sent, s.created_at
FROM schedules s
JOIN schedule_messages m ON m.schedule_id = s.id
WHERE m.message_id = $1
LIMIT 1
`
row := r.db.QueryRowContext(ctx, q, messageID)
var sch Schedule
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &sch, nil
}
func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error {
const q = `
UPDATE schedules
SET status = $1
WHERE id = $2
`
_, err := r.db.ExecContext(ctx, q, status, scheduleID)
return err
}
func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*Schedule, error) {
const q = `
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
FROM schedules
WHERE guild_id = $1
AND (requester_id = $2 OR invitee_id = $2)
AND status IN ('pending', 'accepted', 'reschedule_requested')
ORDER BY scheduled_at ASC
LIMIT $3
`
rows, err := r.db.QueryContext(ctx, q, guildID, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Schedule
for rows.Next() {
var sch Schedule
if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
return nil, err
}
out = append(out, &sch)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) {
const q = `
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
FROM schedules
WHERE id = $1
`
row := r.db.QueryRowContext(ctx, q, id)
var sch Schedule
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &sch, nil
}
func (r *Repo) Reschedule(ctx context.Context, id int64, when time.Time) error {
const q = `
UPDATE schedules
SET scheduled_at = $1,
status = 'pending',
reminder_sent = FALSE
WHERE id = $2
`
_, err := r.db.ExecContext(ctx, q, when, id)
return err
}
// ListDueForReminder returns sessions starting between now and now+ahead, not yet reminded.
func (r *Repo) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*Schedule, error) {
const q = `
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
FROM schedules
WHERE reminder_sent = FALSE
AND status IN ('pending', 'accepted', 'reschedule_requested')
AND scheduled_at > $1
AND scheduled_at <= $2
`
rows, err := r.db.QueryContext(ctx, q, now, now.Add(ahead))
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Schedule
for rows.Next() {
var sch Schedule
if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
return nil, err
}
out = append(out, &sch)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error {
const q = `
UPDATE schedules
SET reminder_sent = TRUE
WHERE id = $1
`
_, err := r.db.ExecContext(ctx, q, scheduleID)
return err
}
+90
View File
@@ -19,6 +19,96 @@ 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) 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 {
const q = `
INSERT INTO levelsettings (guild_id, levelsys)
@@ -0,0 +1,44 @@
package usersettingsrepo
import (
"context"
"database/sql"
)
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) SetTimezone(ctx context.Context, userID int64, zone string) error {
const q = `
INSERT INTO user_settings (user_id, timezone)
VALUES ($1, $2)
ON CONFLICT (user_id)
DO UPDATE SET timezone = EXCLUDED.timezone
`
_, err := r.db.ExecContext(ctx, q, userID, zone)
return err
}
func (r *Repo) GetTimezone(ctx context.Context, userID int64) (string, bool, error) {
const q = `
SELECT timezone
FROM user_settings
WHERE user_id = $1
`
row := r.db.QueryRowContext(ctx, q, userID)
var zone string
switch err := row.Scan(&zone); err {
case nil:
return zone, true, nil
case sql.ErrNoRows:
return "", false, nil
default:
return "", false, err
}
}
+40
View File
@@ -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)
}
+32
View File
@@ -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)
}
+53
View File
@@ -0,0 +1,53 @@
package schedule
import (
"context"
"time"
"velox-bot/internal/db/repos/schedulerepo"
)
type Service struct {
repo *schedulerepo.Repo
}
func New(repo *schedulerepo.Repo) *Service {
return &Service{repo: repo}
}
func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) {
return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description)
}
func (s *Service) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
return s.repo.AddMessage(ctx, scheduleID, messageID, channelID, isDM)
}
func (s *Service) GetByMessageID(ctx context.Context, messageID int64) (*schedulerepo.Schedule, error) {
return s.repo.GetByMessageID(ctx, messageID)
}
func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error {
return s.repo.UpdateStatus(ctx, scheduleID, status)
}
func (s *Service) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*schedulerepo.Schedule, error) {
return s.repo.ListForUser(ctx, guildID, userID, limit)
}
func (s *Service) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*schedulerepo.Schedule, error) {
return s.repo.ListDueForReminder(ctx, now, ahead)
}
func (s *Service) MarkReminded(ctx context.Context, scheduleID int64) error {
return s.repo.MarkReminded(ctx, scheduleID)
}
func (s *Service) GetByID(ctx context.Context, id int64) (*schedulerepo.Schedule, error) {
return s.repo.GetByID(ctx, id)
}
func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) error {
return s.repo.Reschedule(ctx, id, when)
}
+13 -1
View File
@@ -3,21 +3,33 @@ 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/schedule"
"velox-bot/internal/db/services/usersettings"
"velox-bot/internal/db/services/projects"
"velox-bot/internal/db/services/rps"
)
type Services struct {
Level *level.Service
LevelSettings *levelsettings.Service
Meeting *meeting.Service
Schedule *schedule.Service
UserSettings *usersettings.Service
Projects *projects.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, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service) *Services {
s := &Services{
Level: level,
LevelSettings: levelSettings,
Meeting: meeting,
Schedule: schedule,
UserSettings: userSettings,
Projects: projects,
RPS: rps,
}
Global = s
@@ -0,0 +1,42 @@
package usersettings
import (
"context"
"time"
"velox-bot/internal/db/repos/usersettingsrepo"
)
type Service struct {
repo *usersettingsrepo.Repo
}
func New(repo *usersettingsrepo.Repo) *Service {
return &Service{repo: repo}
}
func (s *Service) SetTimezone(ctx context.Context, userID int64, zone string) error {
// validate zone before storing
if _, err := time.LoadLocation(zone); err != nil {
return err
}
return s.repo.SetTimezone(ctx, userID, zone)
}
// GetTimezone returns the user's location (or UTC) and the zone string and a flag indicating if it was user-defined.
func (s *Service) GetTimezone(ctx context.Context, userID int64) (*time.Location, string, bool, error) {
zone, ok, err := s.repo.GetTimezone(ctx, userID)
if err != nil {
return time.UTC, "UTC", false, err
}
if !ok || zone == "" {
return time.UTC, "UTC", false, nil
}
loc, err := time.LoadLocation(zone)
if err != nil {
// fallback to UTC but indicate not user-defined
return time.UTC, "UTC", false, nil
}
return loc, zone, true, nil
}
+116
View File
@@ -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)
}
+95
View File
@@ -0,0 +1,95 @@
package events
import (
"context"
"log"
"strconv"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// HandleMessageReactionAdd reacts to emoji on schedule DM messages:
// ✅ accept, ❌ decline, 🔁 request reschedule.
func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd, svc *services.Services) {
if svc == nil || svc.Schedule == nil {
return
}
if r == nil || r.UserID == "" || r.MessageID == "" {
return
}
// Ignore bot reactions.
if r.Member != nil && r.Member.User != nil && r.Member.User.Bot {
return
}
msgID64, err := strconv.ParseInt(r.MessageID, 10, 64)
if err != nil || msgID64 == 0 {
return
}
ctx := context.Background()
sch, err := svc.Schedule.GetByMessageID(ctx, msgID64)
if err != nil || sch == nil {
return
}
// Only invitee can react to change status.
if r.UserID != strconv.FormatInt(sch.InviteeID, 10) {
return
}
// Only pending can be transitioned by reaction.
if sch.Status != schedulerepo.StatusPending {
return
}
var newStatus schedulerepo.ScheduleStatus
switch r.Emoji.Name {
case "✅":
newStatus = schedulerepo.StatusAccepted
case "❌":
newStatus = schedulerepo.StatusDeclined
case "🔁":
newStatus = schedulerepo.StatusRescheduleRequested
default:
return
}
if err := svc.Schedule.UpdateStatus(ctx, sch.ID, newStatus); err != nil {
log.Printf("schedule: failed to update status id=%d: %v", sch.ID, err)
return
}
statusText := map[schedulerepo.ScheduleStatus]string{
schedulerepo.StatusAccepted: "accepted ✅",
schedulerepo.StatusDeclined: "declined ❌",
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
}[newStatus]
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
// Notify requester with local time if configured.
if svc.UserSettings != nil {
loc, zone, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
if err != nil {
loc = sch.ScheduledAt.UTC().Location()
zone = "UTC"
}
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
requesterID := strconv.FormatInt(sch.RequesterID, 10)
dmCh, err := s.UserChannelCreate(requesterID)
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM requester about status change: %v", err)
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
package events
import (
"context"
"fmt"
"log"
"strconv"
"time"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// StartScheduleReminderLoop runs a background loop that periodically checks for
// upcoming sessions and sends DM reminders to all participants.
func StartScheduleReminderLoop(s *discordgo.Session, svc *services.Services) {
if svc == nil || svc.Schedule == nil || s == nil {
return
}
const (
interval = time.Minute // check every minute
remindBefore = 10 * time.Minute // remind 10 minutes before
initialDelay = 10 * time.Second
)
go func() {
// small delay so the bot can fully start
time.Sleep(initialDelay)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
ctx := context.Background()
// Find all sessions that start in the next `remindBefore` window and haven't been reminded.
schedules, err := svc.Schedule.ListDueForReminder(ctx, now, remindBefore)
if err != nil {
log.Printf("schedule: reminder query failed: %v", err)
continue
}
for _, sch := range schedules {
if sch == nil {
continue
}
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
desc := sch.Description
// Notify requester with local time if available.
if svc.UserSettings != nil {
locReq, zoneReq, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
if err != nil {
locReq = sch.ScheduledAt.UTC().Location()
zoneReq = "UTC"
}
localReq := sch.ScheduledAt.In(locReq).Format("2006-01-02 15:04")
base := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localReq, zoneReq, sch.InviteeID)
if desc != "" {
base += "\nTopic: " + desc
}
if err := dmUser(s, sch.RequesterID, base); err != nil {
log.Printf("schedule: failed to DM requester reminder: %v", err)
}
}
// Notify invitee with their local time if available.
if svc.UserSettings != nil {
locInv, zoneInv, _, err := svc.UserSettings.GetTimezone(ctx, sch.InviteeID)
if err != nil {
locInv = sch.ScheduledAt.UTC().Location()
zoneInv = "UTC"
}
localInv := sch.ScheduledAt.In(locInv).Format("2006-01-02 15:04")
baseInvitee := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localInv, zoneInv, sch.RequesterID)
if desc != "" {
baseInvitee += "\nTopic: " + desc
}
if err := dmUser(s, sch.InviteeID, baseInvitee); err != nil {
log.Printf("schedule: failed to DM invitee reminder: %v", err)
}
}
if err := svc.Schedule.MarkReminded(ctx, sch.ID); err != nil {
log.Printf("schedule: failed to mark reminder sent for id=%d: %v", sch.ID, err)
}
}
}
}()
}
func dmUser(s *discordgo.Session, userID int64, content string) error {
if s == nil || userID == 0 {
return nil
}
uid := strconv.FormatInt(userID, 10)
ch, err := s.UserChannelCreate(uid)
if err != nil {
return err
}
_, err = s.ChannelMessageSend(ch.ID, content)
return err
}
+15 -1
View File
@@ -11,11 +11,18 @@ import (
"velox-bot/internal/config"
"velox-bot/internal/db"
"velox-bot/internal/db/repos/levelrepo"
"velox-bot/internal/db/repos/projectsrepo"
"velox-bot/internal/db/repos/rpsrepo"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/repos/settingsrepo"
"velox-bot/internal/db/repos/usersettingsrepo"
"velox-bot/internal/db/services"
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/schedule"
"velox-bot/internal/db/services/usersettings"
"velox-bot/internal/db/services/projects"
"velox-bot/internal/db/services/rps"
)
@@ -36,10 +43,17 @@ func main() {
levelRepo := levelrepo.NewRepo(db)
settingsRepo := settingsrepo.NewRepo(db)
rpsRepo := rpsrepo.NewRepo(db)
projectsRepo := projectsrepo.NewRepo(db)
scheduleRepo := schedulerepo.NewRepo(db)
userSettingsRepo := usersettingsrepo.NewRepo(db)
levelService := level.New(levelRepo, settingsRepo)
levelSettingsService := levelsettings.New(settingsRepo)
meetingService := meeting.New(settingsRepo)
scheduleService := schedule.New(scheduleRepo)
userSettingsService := usersettings.New(userSettingsRepo)
projectsService := projects.New(projectsRepo)
rpsService := rps.New(rpsRepo)
services := services.NewServices(levelService, levelSettingsService, rpsService)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services)
if err != nil {
+59 -1
View File
@@ -68,4 +68,62 @@ CREATE TABLE IF NOT EXISTS logsettings (
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)
);
--
-- Scheduling (one-to-one sessions)
--
CREATE TABLE IF NOT EXISTS schedules (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
requester_id BIGINT NOT NULL,
invitee_id BIGINT NOT NULL,
scheduled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
description TEXT,
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS schedule_messages (
schedule_id BIGINT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE,
message_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
is_dm BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (schedule_id, message_id)
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id BIGINT PRIMARY KEY,
timezone TEXT NOT NULL
);