package tickets import ( "bytes" "context" "fmt" "log" "time" "velox-bot/internal/db/repos/ticketrepo" "github.com/bwmarrin/discordgo" ) // Actor is whoever pressed the button or ran the command, with their // permissions in the ticket thread. type Actor struct { Member *discordgo.Member Perms int64 } func (a Actor) userID() string { if a.Member == nil || a.Member.User == nil { return "" } return a.Member.User.ID } // load finds the open ticket behind a thread and its category (nil when the // category was deleted since). func load(ctx context.Context, st Store, threadID string) (*ticketrepo.Ticket, *ticketrepo.Category, error) { t, err := st.GetTicketByThread(ctx, mustID(threadID)) if err != nil { return nil, nil, err } if t == nil { return nil, nil, ErrNotATicket } if t.Status != ticketrepo.StatusOpen { return nil, nil, ErrClosed } var cat *ticketrepo.Category if t.CategoryID != 0 { if cat, err = st.GetCategory(ctx, t.GuildID, t.CategoryID); err != nil { return nil, nil, err } } return t, cat, nil } // updateIntro edits the ticket's first message to show who claimed it and // whether it is closed. It keeps the original text (the details are only in // that message) and is best effort: a deleted first message doesn't stop the // ticket from being claimed or closed. func updateIntro(d Discord, t *ticketrepo.Ticket) { if t.IntroMessage == 0 { return } msg, err := d.ChannelMessage(id(t.ThreadID), id(t.IntroMessage)) if err != nil || len(msg.Embeds) == 0 { return } fresh := IntroEmbed(t, "") e := *msg.Embeds[0] e.Color, e.Footer = fresh.Color, fresh.Footer e.Fields = fresh.Fields embeds := []*discordgo.MessageEmbed{&e} comps := IntroComponents(t.ClaimedBy != 0, t.Status == ticketrepo.StatusClosed) if _, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{ Channel: id(t.ThreadID), ID: id(t.IntroMessage), Embeds: &embeds, Components: &comps, }); err != nil { log.Printf("tickets: updating the first message of ticket %d: %v", t.ID, err) } } // Claim marks the actor as the ticket's handler. Only staff can claim, and a // claimed ticket stays with its first claimer. func Claim(ctx context.Context, d Discord, st Store, threadID string, a Actor) error { t, cat, err := load(ctx, st, threadID) if err != nil { return err } if !IsStaff(a.Member, a.Perms, cat) { return ErrNotStaff } me := mustID(a.userID()) holder, err := st.Claim(ctx, t.ThreadID, me) if err != nil { return err } if holder == 0 { return ErrClosed } if holder != me { return UserError(fmt.Sprintf("<@%d> is already handling this ticket.", holder)) } t.ClaimedBy = me updateIntro(d, t) _, err = d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{ Content: fmt.Sprintf("<@%d> is handling this ticket.", me), AllowedMentions: &discordgo.MessageAllowedMentions{}, }) return err } // CheckCanClose runs before the close form is shown. func CheckCanClose(ctx context.Context, st Store, threadID string, a Actor) error { t, cat, err := load(ctx, st, threadID) if err != nil { return err } if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) { return ErrNotInvolved } return nil } // Close closes the ticket: a note in the thread, a summary with the // transcript in the log channel, the first message's buttons disabled, and // the thread locked and archived. The opener or staff can close. func Close(ctx context.Context, d Discord, st Store, threadID string, a Actor, reason string) error { t, cat, err := load(ctx, st, threadID) if err != nil { return err } me := mustID(a.userID()) if me != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) { return ErrNotInvolved } reason = clip(reason, maxReason) // The transcript is taken before anything else changes in the thread. transcript, count, terr := Transcript(d, t) closed, err := st.Close(ctx, t.ThreadID, me, reason) if err != nil { return err } if !closed { return ErrClosed // someone else closed it a moment ago } t.Status, t.ClosedBy, t.CloseReason, t.ClosedAt = ticketrepo.StatusClosed, me, reason, time.Now() note := fmt.Sprintf("๐Ÿ”’ Closed by <@%d>.", me) if reason != "" { note += " Reason: " + reason } if _, err := d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{ Content: note, AllowedMentions: &discordgo.MessageAllowedMentions{}, }); err != nil { log.Printf("tickets: posting the close note in ticket %d: %v", t.ID, err) } if terr != nil { log.Printf("tickets: transcript of ticket %d: %v", t.ID, terr) } postLog(ctx, d, st, t, transcript, count) updateIntro(d, t) yes := true if _, err := d.ChannelEditComplex(id(t.ThreadID), &discordgo.ChannelEdit{Locked: &yes, Archived: &yes}); err != nil { return fmt.Errorf("locking the thread: %w", err) } return nil } // postLog sends the closed ticket's summary and transcript to the log // channel, if the guild has one. func postLog(ctx context.Context, d Discord, st Store, t *ticketrepo.Ticket, transcript []byte, count int) { s, err := st.GetSettings(ctx, t.GuildID) if err != nil || s.LogChannelID == 0 { return } claimed := "Nobody" if t.ClaimedBy != 0 { claimed = "<@" + id(t.ClaimedBy) + ">" } reason := t.CloseReason if reason == "" { reason = "No reason given" } embed := &discordgo.MessageEmbed{ Title: fmt.Sprintf("Ticket #%04d closed ยท %s", t.Number, clip(t.CategoryName, 60)), Description: "**" + clip(t.Subject, maxSubject) + "**", Color: accent, Fields: []*discordgo.MessageEmbedField{ {Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true}, {Name: "Claimed by", Value: claimed, Inline: true}, {Name: "Closed by", Value: "<@" + id(t.ClosedBy) + ">", Inline: true}, {Name: "Reason", Value: clip(reason, 1024)}, {Name: "Thread", Value: "<#" + id(t.ThreadID) + ">", Inline: true}, {Name: "Messages", Value: fmt.Sprint(count), Inline: true}, {Name: "Open for", Value: humanDuration(t.ClosedAt.Sub(t.CreatedAt)), Inline: true}, }, Timestamp: t.ClosedAt.UTC().Format(time.RFC3339), } send := &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{embed}, AllowedMentions: &discordgo.MessageAllowedMentions{}} if len(transcript) > 0 { send.Files = []*discordgo.File{{ Name: fmt.Sprintf("ticket-%04d.txt", t.Number), ContentType: "text/plain; charset=utf-8", Reader: bytes.NewReader(transcript), }} } if _, err := d.ChannelMessageSendComplex(id(s.LogChannelID), send); err != nil { log.Printf("tickets: posting ticket %d to the log channel: %v", t.ID, err) } } func humanDuration(d time.Duration) string { switch { case d <= 0: return "-" case d < time.Hour: return fmt.Sprintf("%d min", int(d.Minutes()+0.5)) case d < 48*time.Hour: return fmt.Sprintf("%.1f h", d.Hours()) default: return fmt.Sprintf("%.1f days", d.Hours()/24) } } // AddMember brings someone into a ticket. The opener and staff can add. func AddMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error { t, cat, err := load(ctx, st, threadID) if err != nil { return err } if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) { return ErrNotInvolved } if err := d.ThreadMemberAdd(threadID, userID); err != nil { return fmt.Errorf("adding to the thread: %w", err) } _, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{ Content: fmt.Sprintf("<@%s> added <@%s> to this ticket.", a.userID(), userID), AllowedMentions: &discordgo.MessageAllowedMentions{}, }) return err } // RemoveMember takes someone out of a ticket. Only staff can, and never the // opener. func RemoveMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error { t, cat, err := load(ctx, st, threadID) if err != nil { return err } if !IsStaff(a.Member, a.Perms, cat) { return ErrNotStaff } if mustID(userID) == t.OpenerID { return UserError("The person who opened the ticket can't be removed. Close the ticket instead.") } if err := d.ThreadMemberRemove(threadID, userID); err != nil { return fmt.Errorf("removing from the thread: %w", err) } _, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{ Content: fmt.Sprintf("<@%s> removed <@%s> from this ticket.", a.userID(), userID), AllowedMentions: &discordgo.MessageAllowedMentions{}, }) return err } // ThreadDeleted closes the ticket of a thread someone deleted by hand, so it // stops counting towards the opener's limit. func ThreadDeleted(ctx context.Context, st Store, threadID string) { if _, err := st.Close(ctx, mustID(threadID), 0, "Thread deleted"); err != nil { log.Printf("tickets: closing the ticket of deleted thread %s: %v", threadID, err) } }