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]>
172 lines
5.4 KiB
Go
172 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.Color = 0x6b7280
|
|
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
|
|
}
|