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", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add-helper", Description: "Add a helper to an existing project", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "close", Description: "Close (deactivate) a project", }, }, } 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) case "close": handleClose(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) + ">" } // handleCreate just opens the modal - the actual creation happens on // submit, in HandleModalSubmit (components.go). func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { if !shared.RequireManageGuild(s, i) { return } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseModal, Data: &discordgo.InteractionResponseData{ CustomID: "projects:create_modal", Title: "Create a project", Components: []discordgo.MessageComponent{ discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ discordgo.TextInput{ CustomID: "name", Label: "Project name", Style: discordgo.TextInputShort, Required: true, }, }, }, discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ discordgo.TextInput{ CustomID: "description", Label: "Description (optional)", Style: discordgo.TextInputParagraph, Required: false, }, }, }, }, }, }) } // handleAddHelper collects the user via Discord's native user-option (a // modal can't hold one of these), then responds with a select menu of the // guild's active projects instead of making the caller remember/type a // numeric project ID - the actual add-helper call happens on selection, in // HandleComponent (components.go). // handleAddHelper is the first of two dropdown steps: pick a project here, // then HandleComponent (components.go) follows up with a native Discord // user-select to pick who helps, and performs the actual AddHelper call // once both are known. func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) { if !shared.RequireManageGuild(s, i) { return } guildID, ok := shared.ParseGuildID(s, i) if !ok { return } options, err := activeProjectOptions(context.Background(), guildID) if err != nil { shared.RespondEphemeral(s, i, "Failed to load projects.") return } if len(options) == 0 { shared.RespondEphemeral(s, i, "There are no active projects to add a helper to.") return } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Which project should get a new helper?", Flags: discordgo.MessageFlagsEphemeral, Components: []discordgo.MessageComponent{ discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ discordgo.SelectMenu{ CustomID: "projects:add_helper_project_select", Placeholder: "Select a project", Options: options, }, }, }, }, }, }) } // handleClose responds with a select menu of the guild's active projects - // same reasoning as handleAddHelper, picking from a live list beats typing // an ID. The actual deactivation happens on selection, in HandleComponent. func handleClose(s *discordgo.Session, i *discordgo.InteractionCreate) { if !shared.RequireManageGuild(s, i) { return } guildID, ok := shared.ParseGuildID(s, i) if !ok { return } options, err := activeProjectOptions(context.Background(), guildID) if err != nil { shared.RespondEphemeral(s, i, "Failed to load projects.") return } if len(options) == 0 { shared.RespondEphemeral(s, i, "There are no active projects to close.") return } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Which project should be closed?", Flags: discordgo.MessageFlagsEphemeral, Components: []discordgo.MessageComponent{ discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ discordgo.SelectMenu{ CustomID: "projects:close_select", Placeholder: "Select a project", Options: options, }, }, }, }, }, }) } // activeProjectOptions builds select-menu options for the guild's active // projects, truncating name/description to Discord's option limits // (label: 100 chars, description: 100 chars). func activeProjectOptions(ctx context.Context, guildID int64) ([]discordgo.SelectMenuOption, error) { const limit = 25 // Discord's own cap on select menu options items, err := services.Global.Projects.ListActive(ctx, guildID, limit) if err != nil { return nil, err } options := make([]discordgo.SelectMenuOption, 0, len(items)) for _, p := range items { if p == nil { continue } name := p.Name if name == "" { name = "Unnamed project" } options = append(options, discordgo.SelectMenuOption{ Label: truncate(name, 100), Value: strconv.FormatInt(p.ID, 10), Description: truncate(p.Description, 100), }) } return options, nil } func truncate(s string, max int) string { if len(s) <= max { return s } return s[:max] }