diff --git a/internal/commands/commands.go b/internal/commands/commands.go index a0b84f4..717213a 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -5,6 +5,7 @@ import ( "velox-bot/internal/commands/help" cmdlevel "velox-bot/internal/commands/level" "velox-bot/internal/commands/meeting" + "velox-bot/internal/commands/projects" "github.com/bwmarrin/discordgo" ) @@ -23,6 +24,7 @@ var AllCommands = []*discordgo.ApplicationCommand{ cmdlevel.Leaderboard, cmdlevel.Rewards, meeting.Meeting, + projects.Projects, } var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ @@ -39,6 +41,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre "leaderboard": cmdlevel.LeaderboardHandler, "rewards": cmdlevel.RewardsHandler, "meeting": meeting.MeetingHandler, + "projects": projects.ProjectsHandler, } func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { diff --git a/internal/commands/projects/public/projects.go b/internal/commands/projects/public/projects.go new file mode 100644 index 0000000..cc2bc43 --- /dev/null +++ b/internal/commands/projects/public/projects.go @@ -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)+"`.") +} + diff --git a/internal/commands/projects/registry.go b/internal/commands/projects/registry.go new file mode 100644 index 0000000..ff3f81f --- /dev/null +++ b/internal/commands/projects/registry.go @@ -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) } + diff --git a/internal/commands/projects/shared/shared.go b/internal/commands/projects/shared/shared.go new file mode 100644 index 0000000..fac7325 --- /dev/null +++ b/internal/commands/projects/shared/shared.go @@ -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 +} + diff --git a/internal/db/repos/projectsrepo/repo.go b/internal/db/repos/projectsrepo/repo.go new file mode 100644 index 0000000..54383d2 --- /dev/null +++ b/internal/db/repos/projectsrepo/repo.go @@ -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 +} + diff --git a/internal/db/services/projects/service.go b/internal/db/services/projects/service.go new file mode 100644 index 0000000..e1687c8 --- /dev/null +++ b/internal/db/services/projects/service.go @@ -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) +} + diff --git a/internal/db/services/services.go b/internal/db/services/services.go index 103def1..a438dda 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -4,6 +4,7 @@ import ( "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/meeting" + "velox-bot/internal/db/services/projects" "velox-bot/internal/db/services/rps" ) @@ -11,16 +12,18 @@ type Services struct { Level *level.Service LevelSettings *levelsettings.Service Meeting *meeting.Service + Projects *projects.Service RPS *rps.Service } var Global *Services -func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.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{ Level: level, LevelSettings: levelSettings, Meeting: meeting, + Projects: projects, RPS: rps, } Global = s diff --git a/main.go b/main.go index 8073d8a..fe7ea0f 100644 --- a/main.go +++ b/main.go @@ -11,12 +11,14 @@ 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/settingsrepo" "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/projects" "velox-bot/internal/db/services/rps" ) @@ -37,11 +39,13 @@ func main() { levelRepo := levelrepo.NewRepo(db) settingsRepo := settingsrepo.NewRepo(db) rpsRepo := rpsrepo.NewRepo(db) + projectsRepo := projectsrepo.NewRepo(db) levelService := level.New(levelRepo, settingsRepo) levelSettingsService := levelsettings.New(settingsRepo) meetingService := meeting.New(settingsRepo) + projectsService := projects.New(projectsRepo) rpsService := rps.New(rpsRepo) - services := services.NewServices(levelService, levelSettingsService, meetingService, rpsService) + services := services.NewServices(levelService, levelSettingsService, meetingService, projectsService, rpsService) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services) if err != nil { diff --git a/schema.sql b/schema.sql index e647669..716f2dc 100644 --- a/schema.sql +++ b/schema.sql @@ -82,4 +82,22 @@ CREATE TABLE IF NOT EXISTS meeting_temp_channels ( 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) +); + -- \ No newline at end of file