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 }