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]>
272 lines
8.6 KiB
Go
272 lines
8.6 KiB
Go
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)
|
|
}
|
|
}
|