Files
FernandoJVideiraandClaude Opus 5.5 e10e1f7a86 Use a dropdown and a Create ticket button on the ticket panel, in orange
The panel is now one category dropdown and a Create ticket button
instead of a button per category. Discord doesn't send the dropdown's
value with a button click, so each member's pick is remembered in
memory for 15 minutes (per guild and member, capped). Panels posted
before keep working until they're reposted. Every ticket embed is
orange, closed tickets included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:58:19 +01:00

177 lines
5.6 KiB
Go

package tickets
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// Custom IDs. Everything starts with "tickets:" so the interaction router can
// hand it to this package.
const (
idOpenPrefix = "tickets:open:" // older panels: one button per category, + category ID
idSelect = "tickets:select" // panel dropdown
idCreate = "tickets:create" // panel "Create ticket" button
idOpenFormPrefix = "tickets:openform:" // opening form, + category ID
idClaim = "tickets:claim"
idClose = "tickets:close"
idCloseForm = "tickets:closeform"
fieldSubject = "subject"
fieldDetails = "details"
fieldReason = "reason"
)
var customEmojiRe = regexp.MustCompile(`^<(a?):(\w+):(\d+)>$`)
// parseEmoji accepts a Unicode emoji or a custom one written as <:name:id>.
func parseEmoji(s string) *discordgo.ComponentEmoji {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if m := customEmojiRe.FindStringSubmatch(s); m != nil {
return &discordgo.ComponentEmoji{Name: m[2], ID: m[3], Animated: m[1] == "a"}
}
return &discordgo.ComponentEmoji{Name: s}
}
// BuildPanel is the message members open tickets from: an embed with the
// guild's text and each category's description, a dropdown to pick the
// category, and a Create ticket button that opens the form for that pick.
func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.MessageEmbed, []discordgo.MessageComponent) {
embed := &discordgo.MessageEmbed{
Title: clip(s.PanelTitle, 256),
Description: clip(s.PanelDescription, 4000),
Color: accent,
}
if len(cats) > maxCategories {
cats = cats[:maxCategories]
}
opts := make([]discordgo.SelectMenuOption, 0, len(cats))
for _, c := range cats {
opts = append(opts, discordgo.SelectMenuOption{
Label: clip(c.Name, 100),
Value: id(c.ID),
Description: clip(c.Description, 100),
Emoji: parseEmoji(c.Emoji),
})
if c.Description != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: clip(c.Name, 256), Value: clip(c.Description, 1024)})
}
}
return embed, []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.SelectMenu{CustomID: idSelect, Placeholder: "Choose what you need help with", Options: opts},
}},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: "Create ticket", Style: discordgo.PrimaryButton, CustomID: idCreate, Emoji: &discordgo.ComponentEmoji{Name: "🎫"}},
}},
}
}
// PostPanel posts the panel in the ticket channel, or updates the one it
// posted before, and returns where it ended up.
func PostPanel(ctx context.Context, d Discord, st Store, guildID int64) (*discordgo.Message, error) {
s, err := st.GetSettings(ctx, guildID)
if err != nil {
return nil, err
}
if s.ChannelID == 0 {
return nil, ErrNotSetUp
}
cats, err := st.ListCategories(ctx, guildID)
if err != nil {
return nil, err
}
if len(cats) == 0 {
return nil, ErrNoCategories
}
embed, comps := BuildPanel(s, cats)
if s.PanelMessageID != 0 {
embeds := []*discordgo.MessageEmbed{embed}
msg, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: id(s.ChannelID),
ID: id(s.PanelMessageID),
Embeds: &embeds,
Components: &comps,
})
if err == nil {
return msg, nil
}
// The old panel was deleted or the channel changed: post a new one.
}
msg, err := d.ChannelMessageSendComplex(id(s.ChannelID), &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{embed},
Components: comps,
})
if err != nil {
return nil, fmt.Errorf("posting the panel: %w", err)
}
if mid, err := strconv.ParseInt(msg.ID, 10, 64); err == nil {
if err := st.SetPanelMessage(ctx, guildID, mid); err != nil {
return msg, fmt.Errorf("saving the panel message: %w", err)
}
}
return msg, nil
}
// OpenForm is the short form shown after a member picks a category.
func OpenForm(cat *ticketrepo.Category) *discordgo.InteractionResponse {
return &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: idOpenFormPrefix + id(cat.ID),
Title: clip("Open a ticket: "+cat.Name, 45),
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
CustomID: fieldSubject,
Label: "What do you need help with?",
Style: discordgo.TextInputShort,
Placeholder: "A short summary",
Required: true,
MinLength: 3,
MaxLength: maxSubject,
}}},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
CustomID: fieldDetails,
Label: "Details",
Style: discordgo.TextInputParagraph,
Placeholder: "Anything the staff should know (optional)",
Required: false,
MaxLength: maxDetails,
}}},
},
},
}
}
// CloseForm asks for an optional reason before closing.
func CloseForm() *discordgo.InteractionResponse {
return &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: idCloseForm,
Title: "Close this ticket",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
CustomID: fieldReason,
Label: "Reason (optional)",
Style: discordgo.TextInputParagraph,
Required: false,
MaxLength: maxReason,
}}},
},
},
}
}