Members open a ticket from a panel in the ticket channel: one button per category (a dropdown past five), then a short form. Each ticket is a private thread named after the category, number and opener, with a first message that mentions the category's staff role so its members join, and Claim and Close buttons. - Claim: staff (the category's role, or Manage Threads / Manage Server) take a ticket; the first message shows who has it. - Close: the opener or staff close it, with an optional reason. The thread is locked and archived, and the log channel gets a summary and a .txt transcript of the whole thread. - /ticket add|remove|claim|close inside a ticket; /tickets setup, /tickets category add|remove|list and /tickets panel for admins. - Per-member limit on open tickets (default 1), per-server ticket numbers handed out atomically, a deleted thread closes its ticket. - The dashboard asks the bot to post or refresh the panel through ticket_commands (LISTEN/NOTIFY, like music); the listener reconnects if the database connection drops. - Setup warns about missing bot permissions in the ticket and log channels. The flows live in internal/tickets behind small Discord and store interfaces, with unit tests on fakes; the repo and the listener have integration tests (-tags integration, TICKETS_TEST_DB). Needs the ticket tables (velox-dashboard-api migration 000016_tickets). .gitignore gains the /tldr benchmark rules so private chat exports and benchmark results on disk can't be committed from this branch. 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: 0x6b7280,
|
|
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)
|
|
}
|
|
}
|