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]>
177 lines
5.6 KiB
Go
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,
|
|
}}},
|
|
},
|
|
},
|
|
}
|
|
}
|