feat: project add & list
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user