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]>
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
package tickets
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"strconv"
|
|
|
|
"velox-bot/internal/db/repos/ticketrepo"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
)
|
|
|
|
// CheckCanOpen runs before the form is shown, so a member who can't open a
|
|
// ticket finds out before typing anything.
|
|
func CheckCanOpen(ctx context.Context, st Store, guildID, userID, categoryID int64) (*ticketrepo.Settings, *ticketrepo.Category, error) {
|
|
s, err := st.GetSettings(ctx, guildID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !s.Configured || s.ChannelID == 0 {
|
|
return nil, nil, ErrNotSetUp
|
|
}
|
|
if !s.Enabled {
|
|
return nil, nil, ErrDisabled
|
|
}
|
|
cat, err := st.GetCategory(ctx, guildID, categoryID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if cat == nil {
|
|
return nil, nil, ErrUnknownCategory
|
|
}
|
|
open, err := st.CountOpenByOpener(ctx, guildID, userID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if open >= s.MaxOpenPerUser {
|
|
if s.MaxOpenPerUser == 1 {
|
|
return nil, nil, UserError("You already have an open ticket. Close it before opening another one.")
|
|
}
|
|
return nil, nil, UserError(fmt.Sprintf("You already have %d open tickets, the most this server allows. Close one before opening another.", open))
|
|
}
|
|
return s, cat, nil
|
|
}
|
|
|
|
type OpenRequest struct {
|
|
GuildID int64
|
|
CategoryID int64
|
|
User *discordgo.User
|
|
Subject string
|
|
Details string
|
|
}
|
|
|
|
// IntroComponents are the Claim and Close buttons under a ticket's first
|
|
// message, disabled as the ticket moves on.
|
|
func IntroComponents(claimed, closed bool) []discordgo.MessageComponent {
|
|
claimLabel := "Claim"
|
|
if claimed {
|
|
claimLabel = "Claimed"
|
|
}
|
|
return []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
|
discordgo.Button{Label: claimLabel, Style: discordgo.SuccessButton, CustomID: idClaim, Disabled: claimed || closed, Emoji: &discordgo.ComponentEmoji{Name: "🙋"}},
|
|
discordgo.Button{Label: "Close", Style: discordgo.DangerButton, CustomID: idClose, Disabled: closed, Emoji: &discordgo.ComponentEmoji{Name: "🔒"}},
|
|
}}}
|
|
}
|
|
|
|
// IntroEmbed describes the ticket at the top of its thread.
|
|
func IntroEmbed(t *ticketrepo.Ticket, details string) *discordgo.MessageEmbed {
|
|
desc := "**" + clip(t.Subject, maxSubject) + "**"
|
|
if details != "" {
|
|
desc += "\n\n" + clip(details, maxDetails)
|
|
}
|
|
claimed := "Nobody yet"
|
|
if t.ClaimedBy != 0 {
|
|
claimed = "<@" + id(t.ClaimedBy) + ">"
|
|
}
|
|
e := &discordgo.MessageEmbed{
|
|
Title: fmt.Sprintf("Ticket #%04d · %s", t.Number, clip(t.CategoryName, 60)),
|
|
Description: desc,
|
|
Color: accent,
|
|
Fields: []*discordgo.MessageEmbedField{
|
|
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
|
|
{Name: "Claimed by", Value: claimed, Inline: true},
|
|
},
|
|
Footer: &discordgo.MessageEmbedFooter{Text: "Staff can claim this ticket. Anyone here can close it once it's sorted."},
|
|
}
|
|
if t.Status == ticketrepo.StatusClosed {
|
|
e.Footer.Text = "This ticket is closed."
|
|
}
|
|
return e
|
|
}
|
|
|
|
// Open creates the ticket: a private thread in the ticket channel with the
|
|
// opener added, a first message that mentions the category's staff role
|
|
// (which adds that role's members to the thread), and the database row.
|
|
func Open(ctx context.Context, d Discord, st Store, req OpenRequest) (*discordgo.Channel, error) {
|
|
s, cat, err := CheckCanOpen(ctx, st, req.GuildID, mustID(req.User.ID), req.CategoryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
number, err := st.NextNumber(ctx, req.GuildID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
name := req.User.GlobalName
|
|
if name == "" {
|
|
name = req.User.Username
|
|
}
|
|
thread, err := d.ThreadStartComplex(id(s.ChannelID), &discordgo.ThreadStart{
|
|
Name: ThreadName(cat.Name, number, name),
|
|
Type: discordgo.ChannelTypeGuildPrivateThread,
|
|
AutoArchiveDuration: autoArchiveWeek,
|
|
Invitable: false, // only staff can add people
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating the thread: %w", err)
|
|
}
|
|
// From here on a failure leaves an empty thread behind unless it's removed.
|
|
fail := func(err error) (*discordgo.Channel, error) {
|
|
if _, derr := d.ChannelDelete(thread.ID); derr != nil {
|
|
log.Printf("tickets: removing thread %s after a failed open: %v", thread.ID, derr)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if err := d.ThreadMemberAdd(thread.ID, req.User.ID); err != nil {
|
|
return fail(fmt.Errorf("adding the opener: %w", err))
|
|
}
|
|
|
|
t := &ticketrepo.Ticket{
|
|
GuildID: req.GuildID,
|
|
Number: number,
|
|
CategoryID: cat.ID,
|
|
CategoryName: cat.Name,
|
|
OpenerID: mustID(req.User.ID),
|
|
Subject: clip(req.Subject, maxSubject),
|
|
Status: ticketrepo.StatusOpen,
|
|
}
|
|
content := "<@" + req.User.ID + ">"
|
|
mentions := &discordgo.MessageAllowedMentions{Users: []string{req.User.ID}}
|
|
if cat.StaffRoleID != 0 {
|
|
content += " <@&" + id(cat.StaffRoleID) + ">"
|
|
mentions.Roles = []string{id(cat.StaffRoleID)}
|
|
}
|
|
intro, err := d.ChannelMessageSendComplex(thread.ID, &discordgo.MessageSend{
|
|
Content: content,
|
|
Embeds: []*discordgo.MessageEmbed{IntroEmbed(t, req.Details)},
|
|
Components: IntroComponents(false, false),
|
|
AllowedMentions: mentions,
|
|
})
|
|
if err != nil {
|
|
return fail(fmt.Errorf("posting the first message: %w", err))
|
|
}
|
|
|
|
t.ThreadID = mustID(thread.ID)
|
|
t.IntroMessage = mustID(intro.ID)
|
|
if _, err := st.CreateTicket(ctx, *t); err != nil {
|
|
return fail(fmt.Errorf("saving the ticket: %w", err))
|
|
}
|
|
return thread, nil
|
|
}
|
|
|
|
// mustID parses a snowflake that came from Discord; those are always valid.
|
|
func mustID(s string) int64 {
|
|
n, _ := strconv.ParseInt(s, 10, 64)
|
|
return n
|
|
}
|